1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
11 GetPersonDetailsResponse,
19 } from "lemmy-js-client";
20 import moment from "moment";
21 import { Subscription } from "rxjs";
22 import { i18n } from "../../i18next";
23 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
24 import { UserService, WebSocketService } from "../../services";
27 capitalizeFirstLetter,
29 createPostLikeFindRes,
43 restoreScrollPosition,
54 import { BannerIconHeader } from "../common/banner-icon-header";
55 import { HtmlTags } from "../common/html-tags";
56 import { Icon, Spinner } from "../common/icon";
57 import { MomentTime } from "../common/moment-time";
58 import { SortSelect } from "../common/sort-select";
59 import { CommunityLink } from "../community/community-link";
60 import { PersonDetails } from "./person-details";
61 import { PersonListing } from "./person-listing";
63 interface ProfileState {
64 personRes?: GetPersonDetailsResponse;
66 view: PersonDetailsView;
70 personBlocked: boolean;
72 banExpireDays?: number;
73 showBanDialog: boolean;
75 siteRes: GetSiteResponse;
78 interface ProfileProps {
79 view: PersonDetailsView;
92 export class Profile extends Component<any, ProfileState> {
93 private isoData = setIsoData(this.context);
94 private subscription?: Subscription;
95 state: 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,
107 constructor(props: any, context: any) {
108 super(props, context);
110 this.handleSortChange = this.handleSortChange.bind(this);
111 this.handlePageChange = this.handlePageChange.bind(this);
113 this.parseMessage = this.parseMessage.bind(this);
114 this.subscription = wsSubscribe(this.parseMessage);
116 // Only fetch the data if coming from another route
117 if (this.isoData.path == this.context.router.route.match.url) {
120 personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
124 this.fetchUserData();
129 let form: GetPersonDetails = {
130 username: this.state.userName,
131 sort: this.state.sort,
132 saved_only: this.state.view === PersonDetailsView.Saved,
133 page: this.state.page,
137 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
140 get amCurrentUser() {
142 UserService.Instance.myUserInfo?.local_user_view.person.id ==
143 this.state.personRes?.person_view.person.id
148 let mui = UserService.Instance.myUserInfo;
149 let res = this.state.personRes;
152 personBlocked: mui.person_blocks
153 .map(a => a.target.id)
154 .includes(res.person_view.person.id),
159 static getViewFromProps(view: string): PersonDetailsView {
160 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
163 static getSortTypeFromProps(sort: string): SortType {
164 return sort ? routeSortTypeToEnum(sort) : SortType.New;
167 static getPageFromProps(page: number): number {
168 return page ? Number(page) : 1;
171 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
172 let pathSplit = req.path.split("/");
174 let username = pathSplit[2];
175 let view = this.getViewFromProps(pathSplit[4]);
176 let sort = this.getSortTypeFromProps(pathSplit[6]);
177 let page = this.getPageFromProps(Number(pathSplit[8]));
179 let form: GetPersonDetails = {
182 saved_only: view === PersonDetailsView.Saved,
187 return [req.client.getPersonDetails(form)];
190 componentDidMount() {
191 this.setPersonBlock();
195 componentWillUnmount() {
196 this.subscription?.unsubscribe();
197 saveScrollPosition(this.context);
200 static getDerivedStateFromProps(props: any): ProfileProps {
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),
206 username: props.match.params.username,
210 componentDidUpdate(lastProps: any) {
211 // Necessary if you are on a post and you click another post (same route)
213 lastProps.location.pathname.split("/")[2] !==
214 lastProps.history.location.pathname.split("/")[2]
216 // Couldnt get a refresh working. This does for now.
221 get documentTitle(): string {
222 let res = this.state.personRes;
224 ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
229 let res = this.state.personRes;
231 <div className="container-lg">
232 {this.state.loading ? (
238 <div className="row">
239 <div className="col-12 col-md-8">
242 title={this.documentTitle}
243 path={this.context.router.route.match.url}
244 description={res.person_view.person.bio}
245 image={res.person_view.person.avatar}
250 {!this.state.loading && this.selects()}
253 admins={this.state.siteRes.admins}
254 sort={this.state.sort}
255 page={this.state.page}
257 enableDownvotes={enableDownvotes(this.state.siteRes)}
258 enableNsfw={enableNsfw(this.state.siteRes)}
259 view={this.state.view}
260 onPageChange={this.handlePageChange}
261 allLanguages={this.state.siteRes.all_languages}
262 siteLanguages={this.state.siteRes.discussion_languages}
266 {!this.state.loading && (
267 <div className="col-12 col-md-4">
269 {this.amCurrentUser && this.follows()}
281 <div className="btn-group btn-group-toggle flex-wrap mb-2">
283 className={`btn btn-outline-secondary pointer
284 ${this.state.view == PersonDetailsView.Overview && "active"}
289 value={PersonDetailsView.Overview}
290 checked={this.state.view === PersonDetailsView.Overview}
291 onChange={linkEvent(this, this.handleViewChange)}
296 className={`btn btn-outline-secondary pointer
297 ${this.state.view == PersonDetailsView.Comments && "active"}
302 value={PersonDetailsView.Comments}
303 checked={this.state.view == PersonDetailsView.Comments}
304 onChange={linkEvent(this, this.handleViewChange)}
309 className={`btn btn-outline-secondary pointer
310 ${this.state.view == PersonDetailsView.Posts && "active"}
315 value={PersonDetailsView.Posts}
316 checked={this.state.view == PersonDetailsView.Posts}
317 onChange={linkEvent(this, this.handleViewChange)}
322 className={`btn btn-outline-secondary pointer
323 ${this.state.view == PersonDetailsView.Saved && "active"}
328 value={PersonDetailsView.Saved}
329 checked={this.state.view == PersonDetailsView.Saved}
330 onChange={linkEvent(this, this.handleViewChange)}
339 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
342 <div className="mb-2">
343 <span className="mr-3">{this.viewRadios()}</span>
345 sort={this.state.sort}
346 onChange={this.handleSortChange}
350 <a href={profileRss} rel={relTags} title="RSS">
351 <Icon icon="rss" classes="text-muted small mx-2" />
353 <link rel="alternate" type="application/atom+xml" href={profileRss} />
357 handleBlockPerson(personId: number) {
361 let blockUserForm: BlockPerson = {
366 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
370 handleUnblockPerson(recipientId: number) {
373 let blockUserForm: BlockPerson = {
374 person_id: recipientId,
378 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
383 let pv = this.state.personRes?.person_view;
387 {!isBanned(pv.person) && (
389 banner={pv.person.banner}
390 icon={pv.person.avatar}
393 <div className="mb-3">
395 <div className="mb-0 d-flex flex-wrap">
397 {pv.person.display_name && (
398 <h5 className="mb-0">{pv.person.display_name}</h5>
400 <ul className="list-inline mb-2">
401 <li className="list-inline-item">
410 {isBanned(pv.person) && (
411 <li className="list-inline-item badge badge-danger">
415 {pv.person.deleted && (
416 <li className="list-inline-item badge badge-danger">
420 {pv.person.admin && (
421 <li className="list-inline-item badge badge-light">
425 {pv.person.bot_account && (
426 <li className="list-inline-item badge badge-light">
427 {i18n.t("bot_account").toLowerCase()}
433 <div className="flex-grow-1 unselectable pointer mx-2"></div>
434 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
437 className={`d-flex align-self-start btn btn-secondary mr-2 ${
438 !pv.person.matrix_user_id && "invisible"
441 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
443 {i18n.t("send_secure_message")}
447 "d-flex align-self-start btn btn-secondary mr-2"
449 to={`/create_private_message/recipient/${pv.person.id}`}
451 {i18n.t("send_message")}
453 {this.state.personBlocked ? (
456 "d-flex align-self-start btn btn-secondary mr-2"
460 this.handleUnblockPerson
463 {i18n.t("unblock_user")}
468 "d-flex align-self-start btn btn-secondary mr-2"
472 this.handleBlockPerson
475 {i18n.t("block_user")}
481 {canMod(pv.person.id, undefined, this.state.siteRes.admins) &&
482 !isAdmin(pv.person.id, this.state.siteRes.admins) &&
483 !this.state.showBanDialog &&
484 (!isBanned(pv.person) ? (
487 "d-flex align-self-start btn btn-secondary mr-2"
489 onClick={linkEvent(this, this.handleModBanShow)}
490 aria-label={i18n.t("ban")}
492 {capitalizeFirstLetter(i18n.t("ban"))}
497 "d-flex align-self-start btn btn-secondary mr-2"
499 onClick={linkEvent(this, this.handleModBanSubmit)}
500 aria-label={i18n.t("unban")}
502 {capitalizeFirstLetter(i18n.t("unban"))}
507 <div className="d-flex align-items-center mb-2">
510 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
515 <ul className="list-inline mb-2">
516 <li className="list-inline-item badge badge-light">
517 {i18n.t("number_of_posts", {
518 count: pv.counts.post_count,
519 formattedCount: numToSI(pv.counts.post_count),
522 <li className="list-inline-item badge badge-light">
523 {i18n.t("number_of_comments", {
524 count: pv.counts.comment_count,
525 formattedCount: numToSI(pv.counts.comment_count),
530 <div className="text-muted">
531 {i18n.t("joined")}{" "}
533 published={pv.person.published}
538 <div className="d-flex align-items-center text-muted mb-2">
540 <span className="ml-2">
541 {i18n.t("cake_day_title")}{" "}
543 .utc(pv.person.published)
545 .format("MMM DD, YYYY")}
556 let pv = this.state.personRes?.person_view;
560 {this.state.showBanDialog && (
561 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
562 <div className="form-group row col-12">
563 <label className="col-form-label" htmlFor="profile-ban-reason">
568 id="profile-ban-reason"
569 className="form-control mr-2"
570 placeholder={i18n.t("reason")}
571 value={this.state.banReason}
572 onInput={linkEvent(this, this.handleModBanReasonChange)}
574 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
579 id={`mod-ban-expires`}
580 className="form-control mr-2"
581 placeholder={i18n.t("number_of_days")}
582 value={this.state.banExpireDays}
583 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
585 <div className="form-group">
586 <div className="form-check">
588 className="form-check-input"
589 id="mod-ban-remove-data"
591 checked={this.state.removeData}
592 onChange={linkEvent(this, this.handleModRemoveDataChange)}
595 className="form-check-label"
596 htmlFor="mod-ban-remove-data"
597 title={i18n.t("remove_content_more")}
599 {i18n.t("remove_content")}
604 {/* TODO hold off on expires until later */}
605 {/* <div class="form-group row"> */}
606 {/* <label class="col-form-label">Expires</label> */}
607 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
609 <div className="form-group row">
612 className="btn btn-secondary mr-2"
613 aria-label={i18n.t("cancel")}
614 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
620 className="btn btn-secondary"
621 aria-label={i18n.t("ban")}
623 {i18n.t("ban")} {pv.person.name}
634 let moderates = this.state.personRes?.moderates;
637 moderates.length > 0 && (
638 <div className="card border-secondary mb-3">
639 <div className="card-body">
640 <h5>{i18n.t("moderates")}</h5>
641 <ul className="list-unstyled mb-0">
642 {moderates.map(cmv => (
643 <li key={cmv.community.id}>
644 <CommunityLink community={cmv.community} />
655 let follows = UserService.Instance.myUserInfo?.follows;
658 follows.length > 0 && (
659 <div className="card border-secondary mb-3">
660 <div className="card-body">
661 <h5>{i18n.t("subscribed")}</h5>
662 <ul className="list-unstyled mb-0">
663 {follows.map(cfv => (
664 <li key={cfv.community.id}>
665 <CommunityLink community={cfv.community} />
675 updateUrl(paramUpdates: UrlParams) {
676 const page = paramUpdates.page || this.state.page;
677 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
678 const sortStr = paramUpdates.sort || this.state.sort;
680 let typeView = `/u/${this.state.userName}`;
682 this.props.history.push(
683 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
685 this.setState({ loading: true });
686 this.fetchUserData();
689 handlePageChange(page: number) {
690 this.updateUrl({ page: page });
693 handleSortChange(val: SortType) {
694 this.updateUrl({ sort: val, page: 1 });
697 handleViewChange(i: Profile, event: any) {
699 view: PersonDetailsView[Number(event.target.value)],
704 handleModBanShow(i: Profile) {
705 i.setState({ showBanDialog: true });
708 handleModBanReasonChange(i: Profile, event: any) {
709 i.setState({ banReason: event.target.value });
712 handleModBanExpireDaysChange(i: Profile, event: any) {
713 i.setState({ banExpireDays: event.target.value });
716 handleModRemoveDataChange(i: Profile, event: any) {
717 i.setState({ removeData: event.target.checked });
720 handleModBanSubmitCancel(i: Profile, event?: any) {
721 event.preventDefault();
722 i.setState({ showBanDialog: false });
725 handleModBanSubmit(i: Profile, event?: any) {
726 if (event) event.preventDefault();
727 let person = i.state.personRes?.person_view.person;
729 if (person && auth) {
730 // If its an unban, restore all their data
731 let ban = !person.banned;
733 i.setState({ removeData: false });
735 let form: BanPerson = {
736 person_id: person.id,
738 remove_data: i.state.removeData,
739 reason: i.state.banReason,
740 expires: futureDaysToUnixTime(i.state.banExpireDays),
743 WebSocketService.Instance.send(wsClient.banPerson(form));
745 i.setState({ showBanDialog: false });
749 parseMessage(msg: any) {
750 let op = wsUserOp(msg);
753 toast(i18n.t(msg.error), "danger");
754 if (msg.error == "couldnt_find_that_username_or_email") {
755 this.context.router.history.push("/");
758 } else if (msg.reconnect) {
759 this.fetchUserData();
760 } else if (op == UserOperation.GetPersonDetails) {
761 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
762 // and set the parent state if it is not set or differs
763 // TODO this might need to get abstracted
764 let data = wsJsonToRes<GetPersonDetailsResponse>(msg);
765 this.setState({ personRes: data, loading: false });
766 this.setPersonBlock();
767 restoreScrollPosition(this.context);
768 } else if (op == UserOperation.AddAdmin) {
769 let data = wsJsonToRes<AddAdminResponse>(msg);
770 this.setState(s => ((s.siteRes.admins = data.admins), s));
771 } else if (op == UserOperation.CreateCommentLike) {
772 let data = wsJsonToRes<CommentResponse>(msg);
773 createCommentLikeRes(data.comment_view, this.state.personRes?.comments);
774 this.setState(this.state);
776 op == UserOperation.EditComment ||
777 op == UserOperation.DeleteComment ||
778 op == UserOperation.RemoveComment
780 let data = wsJsonToRes<CommentResponse>(msg);
781 editCommentRes(data.comment_view, this.state.personRes?.comments);
782 this.setState(this.state);
783 } else if (op == UserOperation.CreateComment) {
784 let data = wsJsonToRes<CommentResponse>(msg);
785 let mui = UserService.Instance.myUserInfo;
786 if (data.comment_view.creator.id == mui?.local_user_view.person.id) {
787 toast(i18n.t("reply_sent"));
789 } else if (op == UserOperation.SaveComment) {
790 let data = wsJsonToRes<CommentResponse>(msg);
791 saveCommentRes(data.comment_view, this.state.personRes?.comments);
792 this.setState(this.state);
794 op == UserOperation.EditPost ||
795 op == UserOperation.DeletePost ||
796 op == UserOperation.RemovePost ||
797 op == UserOperation.LockPost ||
798 op == UserOperation.FeaturePost ||
799 op == UserOperation.SavePost
801 let data = wsJsonToRes<PostResponse>(msg);
802 editPostFindRes(data.post_view, this.state.personRes?.posts);
803 this.setState(this.state);
804 } else if (op == UserOperation.CreatePostLike) {
805 let data = wsJsonToRes<PostResponse>(msg);
806 createPostLikeFindRes(data.post_view, this.state.personRes?.posts);
807 this.setState(this.state);
808 } else if (op == UserOperation.BanPerson) {
809 let data = wsJsonToRes<BanPersonResponse>(msg);
810 let res = this.state.personRes;
812 .filter(c => c.creator.id == data.person_view.person.id)
813 .forEach(c => (c.creator.banned = data.banned));
815 .filter(c => c.creator.id == data.person_view.person.id)
816 .forEach(c => (c.creator.banned = data.banned));
817 let pv = res?.person_view;
819 if (pv?.person.id == data.person_view.person.id) {
820 pv.person.banned = data.banned;
822 this.setState(this.state);
823 } else if (op == UserOperation.BlockPerson) {
824 let data = wsJsonToRes<BlockPersonResponse>(msg);
825 updatePersonBlock(data);
826 this.setPersonBlock();
827 this.setState(this.state);
829 op == UserOperation.PurgePerson ||
830 op == UserOperation.PurgePost ||
831 op == UserOperation.PurgeComment ||
832 op == UserOperation.PurgeCommunity
834 let data = wsJsonToRes<PurgeItemResponse>(msg);
836 toast(i18n.t("purge_success"));
837 this.context.router.history.push(`/`);