1 import { None, Option, Some } from "@sniptt/monads";
2 import { Component, linkEvent } from "inferno";
3 import { Link } from "inferno-router";
12 GetPersonDetailsResponse,
21 } from "lemmy-js-client";
22 import moment from "moment";
23 import { Subscription } from "rxjs";
24 import { i18n } from "../../i18next";
25 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
26 import { UserService, WebSocketService } from "../../services";
30 capitalizeFirstLetter,
32 createPostLikeFindRes,
45 restoreScrollPosition,
56 import { BannerIconHeader } from "../common/banner-icon-header";
57 import { HtmlTags } from "../common/html-tags";
58 import { Icon, Spinner } from "../common/icon";
59 import { MomentTime } from "../common/moment-time";
60 import { SortSelect } from "../common/sort-select";
61 import { CommunityLink } from "../community/community-link";
62 import { PersonDetails } from "./person-details";
63 import { PersonListing } from "./person-listing";
65 interface ProfileState {
66 personRes: Option<GetPersonDetailsResponse>;
68 view: PersonDetailsView;
72 personBlocked: boolean;
73 banReason: Option<string>;
74 banExpireDays: Option<number>;
75 showBanDialog: boolean;
77 siteRes: GetSiteResponse;
80 interface ProfileProps {
81 view: PersonDetailsView;
84 person_id: number | null;
94 export class Profile extends Component<any, ProfileState> {
95 private isoData = setIsoData(this.context, GetPersonDetailsResponse);
96 private subscription: Subscription;
97 private emptyState: ProfileState = {
99 userName: getUsernameFromProps(this.props),
101 view: Profile.getViewFromProps(this.props.match.view),
102 sort: Profile.getSortTypeFromProps(this.props.match.sort),
103 page: Profile.getPageFromProps(this.props.match.page),
104 personBlocked: false,
105 siteRes: this.isoData.site_res,
106 showBanDialog: false,
112 constructor(props: any, context: any) {
113 super(props, context);
115 this.state = this.emptyState;
116 this.handleSortChange = this.handleSortChange.bind(this);
117 this.handlePageChange = this.handlePageChange.bind(this);
119 this.parseMessage = this.parseMessage.bind(this);
120 this.subscription = wsSubscribe(this.parseMessage);
122 // Only fetch the data if coming from another route
123 if (this.isoData.path == this.context.router.route.match.url) {
124 this.state.personRes = Some(
125 this.isoData.routeData[0] as GetPersonDetailsResponse
127 this.state.loading = false;
129 this.fetchUserData();
132 this.setPersonBlock();
136 let form = new GetPersonDetails({
137 username: Some(this.state.userName),
140 sort: Some(this.state.sort),
141 saved_only: Some(this.state.view === PersonDetailsView.Saved),
142 page: Some(this.state.page),
143 limit: Some(fetchLimit),
144 auth: auth(false).ok(),
146 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
149 get amCurrentUser() {
150 return UserService.Instance.myUserInfo.match({
152 this.state.personRes.match({
154 mui.local_user_view.person.id == res.person_view.person.id,
162 UserService.Instance.myUserInfo.match({
164 this.state.personRes.match({
166 this.state.personBlocked = mui.person_blocks
167 .map(a => a.target.id)
168 .includes(res.person_view.person.id);
176 static getViewFromProps(view: string): PersonDetailsView {
177 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
180 static getSortTypeFromProps(sort: string): SortType {
181 return sort ? routeSortTypeToEnum(sort) : SortType.New;
184 static getPageFromProps(page: number): number {
185 return page ? Number(page) : 1;
188 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
189 let pathSplit = req.path.split("/");
191 let username = pathSplit[2];
192 let view = this.getViewFromProps(pathSplit[4]);
193 let sort = Some(this.getSortTypeFromProps(pathSplit[6]));
194 let page = Some(this.getPageFromProps(Number(pathSplit[8])));
196 let form = new GetPersonDetails({
197 username: Some(username),
201 saved_only: Some(view === PersonDetailsView.Saved),
203 limit: Some(fetchLimit),
206 return [req.client.getPersonDetails(form)];
209 componentDidMount() {
213 componentWillUnmount() {
214 this.subscription.unsubscribe();
215 saveScrollPosition(this.context);
218 static getDerivedStateFromProps(props: any): ProfileProps {
220 view: this.getViewFromProps(props.match.params.view),
221 sort: this.getSortTypeFromProps(props.match.params.sort),
222 page: this.getPageFromProps(props.match.params.page),
223 person_id: Number(props.match.params.id) || null,
224 username: props.match.params.username,
228 componentDidUpdate(lastProps: any) {
229 // Necessary if you are on a post and you click another post (same route)
231 lastProps.location.pathname.split("/")[2] !==
232 lastProps.history.location.pathname.split("/")[2]
234 // Couldnt get a refresh working. This does for now.
239 get documentTitle(): string {
240 return this.state.siteRes.site_view.match({
242 this.state.personRes.match({
244 `@${res.person_view.person.name} - ${siteView.site.name}`,
253 <div class="container">
254 {this.state.loading ? (
259 this.state.personRes.match({
262 <div class="col-12 col-md-8">
265 title={this.documentTitle}
266 path={this.context.router.route.match.url}
267 description={res.person_view.person.bio}
268 image={res.person_view.person.avatar}
273 {!this.state.loading && this.selects()}
276 admins={this.state.siteRes.admins}
277 sort={this.state.sort}
278 page={this.state.page}
280 enableDownvotes={enableDownvotes(this.state.siteRes)}
281 enableNsfw={enableNsfw(this.state.siteRes)}
282 view={this.state.view}
283 onPageChange={this.handlePageChange}
287 {!this.state.loading && (
288 <div class="col-12 col-md-4">
290 {this.amCurrentUser && this.follows()}
304 <div class="btn-group btn-group-toggle flex-wrap mb-2">
306 className={`btn btn-outline-secondary pointer
307 ${this.state.view == PersonDetailsView.Overview && "active"}
312 value={PersonDetailsView.Overview}
313 checked={this.state.view === PersonDetailsView.Overview}
314 onChange={linkEvent(this, this.handleViewChange)}
319 className={`btn btn-outline-secondary pointer
320 ${this.state.view == PersonDetailsView.Comments && "active"}
325 value={PersonDetailsView.Comments}
326 checked={this.state.view == PersonDetailsView.Comments}
327 onChange={linkEvent(this, this.handleViewChange)}
332 className={`btn btn-outline-secondary pointer
333 ${this.state.view == PersonDetailsView.Posts && "active"}
338 value={PersonDetailsView.Posts}
339 checked={this.state.view == PersonDetailsView.Posts}
340 onChange={linkEvent(this, this.handleViewChange)}
345 className={`btn btn-outline-secondary pointer
346 ${this.state.view == PersonDetailsView.Saved && "active"}
351 value={PersonDetailsView.Saved}
352 checked={this.state.view == PersonDetailsView.Saved}
353 onChange={linkEvent(this, this.handleViewChange)}
362 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
365 <div className="mb-2">
366 <span class="mr-3">{this.viewRadios()}</span>
368 sort={this.state.sort}
369 onChange={this.handleSortChange}
373 <a href={profileRss} rel={relTags} title="RSS">
374 <Icon icon="rss" classes="text-muted small mx-2" />
376 <link rel="alternate" type="application/atom+xml" href={profileRss} />
380 handleBlockPerson(personId: number) {
382 let blockUserForm = new BlockPerson({
385 auth: auth().unwrap(),
387 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
390 handleUnblockPerson(recipientId: number) {
391 let blockUserForm = new BlockPerson({
392 person_id: recipientId,
394 auth: auth().unwrap(),
396 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
400 return this.state.personRes
401 .map(r => r.person_view)
406 banner={pv.person.banner}
407 icon={pv.person.avatar}
411 <div class="mb-0 d-flex flex-wrap">
413 {pv.person.display_name && (
414 <h5 class="mb-0">{pv.person.display_name}</h5>
416 <ul class="list-inline mb-2">
417 <li className="list-inline-item">
426 {isBanned(pv.person) && (
427 <li className="list-inline-item badge badge-danger">
431 {pv.person.admin && (
432 <li className="list-inline-item badge badge-light">
436 {pv.person.bot_account && (
437 <li className="list-inline-item badge badge-light">
438 {i18n.t("bot_account").toLowerCase()}
444 <div className="flex-grow-1 unselectable pointer mx-2"></div>
445 {!this.amCurrentUser &&
446 UserService.Instance.myUserInfo.isSome() && (
449 className={`d-flex align-self-start btn btn-secondary mr-2 ${
450 !pv.person.matrix_user_id && "invisible"
453 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
455 {i18n.t("send_secure_message")}
459 "d-flex align-self-start btn btn-secondary mr-2"
461 to={`/create_private_message/recipient/${pv.person.id}`}
463 {i18n.t("send_message")}
465 {this.state.personBlocked ? (
468 "d-flex align-self-start btn btn-secondary mr-2"
472 this.handleUnblockPerson
475 {i18n.t("unblock_user")}
480 "d-flex align-self-start btn btn-secondary mr-2"
484 this.handleBlockPerson
487 {i18n.t("block_user")}
495 Some(this.state.siteRes.admins),
498 !isAdmin(Some(this.state.siteRes.admins), pv.person.id) &&
499 !this.state.showBanDialog &&
500 (!isBanned(pv.person) ? (
503 "d-flex align-self-start btn btn-secondary mr-2"
505 onClick={linkEvent(this, this.handleModBanShow)}
506 aria-label={i18n.t("ban")}
508 {capitalizeFirstLetter(i18n.t("ban"))}
513 "d-flex align-self-start btn btn-secondary mr-2"
515 onClick={linkEvent(this, this.handleModBanSubmit)}
516 aria-label={i18n.t("unban")}
518 {capitalizeFirstLetter(i18n.t("unban"))}
522 {pv.person.bio.match({
524 <div className="d-flex align-items-center mb-2">
527 dangerouslySetInnerHTML={mdToHtml(bio)}
534 <ul class="list-inline mb-2">
535 <li className="list-inline-item badge badge-light">
536 {i18n.t("number_of_posts", {
537 count: pv.counts.post_count,
538 formattedCount: numToSI(pv.counts.post_count),
541 <li className="list-inline-item badge badge-light">
542 {i18n.t("number_of_comments", {
543 count: pv.counts.comment_count,
544 formattedCount: numToSI(pv.counts.comment_count),
549 <div class="text-muted">
550 {i18n.t("joined")}{" "}
552 published={pv.person.published}
558 <div className="d-flex align-items-center text-muted mb-2">
560 <span className="ml-2">
561 {i18n.t("cake_day_title")}{" "}
563 .utc(pv.person.published)
565 .format("MMM DD, YYYY")}
577 return this.state.personRes
578 .map(r => r.person_view)
582 {this.state.showBanDialog && (
583 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
584 <div class="form-group row col-12">
585 <label class="col-form-label" htmlFor="profile-ban-reason">
590 id="profile-ban-reason"
591 class="form-control mr-2"
592 placeholder={i18n.t("reason")}
593 value={toUndefined(this.state.banReason)}
594 onInput={linkEvent(this, this.handleModBanReasonChange)}
596 <label class="col-form-label" htmlFor={`mod-ban-expires`}>
601 id={`mod-ban-expires`}
602 class="form-control mr-2"
603 placeholder={i18n.t("number_of_days")}
604 value={toUndefined(this.state.banExpireDays)}
605 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
607 <div class="form-group">
608 <div class="form-check">
610 class="form-check-input"
611 id="mod-ban-remove-data"
613 checked={this.state.removeData}
616 this.handleModRemoveDataChange
620 class="form-check-label"
621 htmlFor="mod-ban-remove-data"
622 title={i18n.t("remove_content_more")}
624 {i18n.t("remove_content")}
629 {/* TODO hold off on expires until later */}
630 {/* <div class="form-group row"> */}
631 {/* <label class="col-form-label">Expires</label> */}
632 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
634 <div class="form-group row">
637 class="btn btn-secondary mr-2"
638 aria-label={i18n.t("cancel")}
639 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
645 class="btn btn-secondary"
646 aria-label={i18n.t("ban")}
648 {i18n.t("ban")} {pv.person.name}
659 // TODO test this, make sure its good
661 return this.state.personRes
662 .map(r => r.moderates)
665 if (moderates.length > 0) {
666 <div class="card border-secondary mb-3">
667 <div class="card-body">
668 <h5>{i18n.t("moderates")}</h5>
669 <ul class="list-unstyled mb-0">
670 {moderates.map(cmv => (
672 <CommunityLink community={cmv.community} />
685 return UserService.Instance.myUserInfo
689 if (follows.length > 0) {
690 <div class="card border-secondary mb-3">
691 <div class="card-body">
692 <h5>{i18n.t("subscribed")}</h5>
693 <ul class="list-unstyled mb-0">
694 {follows.map(cfv => (
696 <CommunityLink community={cfv.community} />
708 updateUrl(paramUpdates: UrlParams) {
709 const page = paramUpdates.page || this.state.page;
710 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
711 const sortStr = paramUpdates.sort || this.state.sort;
713 let typeView = `/u/${this.state.userName}`;
715 this.props.history.push(
716 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
718 this.state.loading = true;
719 this.setState(this.state);
720 this.fetchUserData();
723 handlePageChange(page: number) {
724 this.updateUrl({ page: page });
727 handleSortChange(val: SortType) {
728 this.updateUrl({ sort: val, page: 1 });
731 handleViewChange(i: Profile, event: any) {
733 view: PersonDetailsView[Number(event.target.value)],
738 handleModBanShow(i: Profile) {
739 i.state.showBanDialog = true;
743 handleModBanReasonChange(i: Profile, event: any) {
744 i.state.banReason = event.target.value;
748 handleModBanExpireDaysChange(i: Profile, event: any) {
749 i.state.banExpireDays = event.target.value;
753 handleModRemoveDataChange(i: Profile, event: any) {
754 i.state.removeData = event.target.checked;
758 handleModBanSubmitCancel(i: Profile, event?: any) {
759 event.preventDefault();
760 i.state.showBanDialog = false;
764 handleModBanSubmit(i: Profile, event?: any) {
765 if (event) event.preventDefault();
768 .map(r => r.person_view.person)
771 // If its an unban, restore all their data
772 let ban = !person.banned;
774 i.state.removeData = false;
776 let form = new BanPerson({
777 person_id: person.id,
779 remove_data: Some(i.state.removeData),
780 reason: i.state.banReason,
781 expires: i.state.banExpireDays.map(futureDaysToUnixTime),
782 auth: auth().unwrap(),
784 WebSocketService.Instance.send(wsClient.banPerson(form));
786 i.state.showBanDialog = false;
793 parseMessage(msg: any) {
794 let op = wsUserOp(msg);
797 toast(i18n.t(msg.error), "danger");
798 if (msg.error == "couldnt_find_that_username_or_email") {
799 this.context.router.history.push("/");
802 } else if (msg.reconnect) {
803 this.fetchUserData();
804 } else if (op == UserOperation.GetPersonDetails) {
805 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
806 // and set the parent state if it is not set or differs
807 // TODO this might need to get abstracted
808 let data = wsJsonToRes<GetPersonDetailsResponse>(
810 GetPersonDetailsResponse
812 this.state.personRes = Some(data);
813 this.state.loading = false;
814 this.setPersonBlock();
815 this.setState(this.state);
816 restoreScrollPosition(this.context);
817 } else if (op == UserOperation.AddAdmin) {
818 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
819 this.state.siteRes.admins = data.admins;
820 this.setState(this.state);
821 } else if (op == UserOperation.CreateCommentLike) {
822 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
823 createCommentLikeRes(
825 this.state.personRes.map(r => r.comments).unwrapOr([])
827 this.setState(this.state);
829 op == UserOperation.EditComment ||
830 op == UserOperation.DeleteComment ||
831 op == UserOperation.RemoveComment
833 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
836 this.state.personRes.map(r => r.comments).unwrapOr([])
838 this.setState(this.state);
839 } else if (op == UserOperation.CreateComment) {
840 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
841 UserService.Instance.myUserInfo.match({
843 if (data.comment_view.creator.id == mui.local_user_view.person.id) {
844 toast(i18n.t("reply_sent"));
849 } else if (op == UserOperation.SaveComment) {
850 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
853 this.state.personRes.map(r => r.comments).unwrapOr([])
855 this.setState(this.state);
857 op == UserOperation.EditPost ||
858 op == UserOperation.DeletePost ||
859 op == UserOperation.RemovePost ||
860 op == UserOperation.LockPost ||
861 op == UserOperation.StickyPost ||
862 op == UserOperation.SavePost
864 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
867 this.state.personRes.map(r => r.posts).unwrapOr([])
869 this.setState(this.state);
870 } else if (op == UserOperation.CreatePostLike) {
871 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
872 createPostLikeFindRes(
874 this.state.personRes.map(r => r.posts).unwrapOr([])
876 this.setState(this.state);
877 } else if (op == UserOperation.BanPerson) {
878 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
879 this.state.personRes.match({
882 .filter(c => c.creator.id == data.person_view.person.id)
883 .forEach(c => (c.creator.banned = data.banned));
885 .filter(c => c.creator.id == data.person_view.person.id)
886 .forEach(c => (c.creator.banned = data.banned));
887 let pv = res.person_view;
889 if (pv.person.id == data.person_view.person.id) {
890 pv.person.banned = data.banned;
892 this.setState(this.state);
896 } else if (op == UserOperation.BlockPerson) {
897 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
898 updatePersonBlock(data);
899 this.setPersonBlock();
900 this.setState(this.state);
902 op == UserOperation.PurgePerson ||
903 op == UserOperation.PurgePost ||
904 op == UserOperation.PurgeComment ||
905 op == UserOperation.PurgeCommunity
907 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
909 toast(i18n.t("purge_success"));
910 this.context.router.history.push(`/`);