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) {
126 personRes: Some(this.isoData.routeData[0] as GetPersonDetailsResponse),
130 this.fetchUserData();
133 this.setPersonBlock();
137 let form = new GetPersonDetails({
138 username: Some(this.state.userName),
141 sort: Some(this.state.sort),
142 saved_only: Some(this.state.view === PersonDetailsView.Saved),
143 page: Some(this.state.page),
144 limit: Some(fetchLimit),
145 auth: auth(false).ok(),
147 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
150 get amCurrentUser() {
151 return UserService.Instance.myUserInfo.match({
153 this.state.personRes.match({
155 mui.local_user_view.person.id == res.person_view.person.id,
163 UserService.Instance.myUserInfo.match({
165 this.state.personRes.match({
168 personBlocked: mui.person_blocks
169 .map(a => a.target.id)
170 .includes(res.person_view.person.id),
178 static getViewFromProps(view: string): PersonDetailsView {
179 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
182 static getSortTypeFromProps(sort: string): SortType {
183 return sort ? routeSortTypeToEnum(sort) : SortType.New;
186 static getPageFromProps(page: number): number {
187 return page ? Number(page) : 1;
190 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
191 let pathSplit = req.path.split("/");
193 let username = pathSplit[2];
194 let view = this.getViewFromProps(pathSplit[4]);
195 let sort = Some(this.getSortTypeFromProps(pathSplit[6]));
196 let page = Some(this.getPageFromProps(Number(pathSplit[8])));
198 let form = new GetPersonDetails({
199 username: Some(username),
203 saved_only: Some(view === PersonDetailsView.Saved),
205 limit: Some(fetchLimit),
208 return [req.client.getPersonDetails(form)];
211 componentDidMount() {
215 componentWillUnmount() {
216 this.subscription.unsubscribe();
217 saveScrollPosition(this.context);
220 static getDerivedStateFromProps(props: any): ProfileProps {
222 view: this.getViewFromProps(props.match.params.view),
223 sort: this.getSortTypeFromProps(props.match.params.sort),
224 page: this.getPageFromProps(props.match.params.page),
225 person_id: Number(props.match.params.id) || null,
226 username: props.match.params.username,
230 componentDidUpdate(lastProps: any) {
231 // Necessary if you are on a post and you click another post (same route)
233 lastProps.location.pathname.split("/")[2] !==
234 lastProps.history.location.pathname.split("/")[2]
236 // Couldnt get a refresh working. This does for now.
241 get documentTitle(): string {
242 return this.state.siteRes.site_view.match({
244 this.state.personRes.match({
246 `@${res.person_view.person.name} - ${siteView.site.name}`,
255 <div className="container">
256 {this.state.loading ? (
261 this.state.personRes.match({
263 <div className="row">
264 <div className="col-12 col-md-8">
267 title={this.documentTitle}
268 path={this.context.router.route.match.url}
269 description={res.person_view.person.bio}
270 image={res.person_view.person.avatar}
275 {!this.state.loading && this.selects()}
278 admins={this.state.siteRes.admins}
279 sort={this.state.sort}
280 page={this.state.page}
282 enableDownvotes={enableDownvotes(this.state.siteRes)}
283 enableNsfw={enableNsfw(this.state.siteRes)}
284 view={this.state.view}
285 onPageChange={this.handlePageChange}
286 allLanguages={this.state.siteRes.all_languages}
290 {!this.state.loading && (
291 <div className="col-12 col-md-4">
293 {this.amCurrentUser && this.follows()}
307 <div className="btn-group btn-group-toggle flex-wrap mb-2">
309 className={`btn btn-outline-secondary pointer
310 ${this.state.view == PersonDetailsView.Overview && "active"}
315 value={PersonDetailsView.Overview}
316 checked={this.state.view === PersonDetailsView.Overview}
317 onChange={linkEvent(this, this.handleViewChange)}
322 className={`btn btn-outline-secondary pointer
323 ${this.state.view == PersonDetailsView.Comments && "active"}
328 value={PersonDetailsView.Comments}
329 checked={this.state.view == PersonDetailsView.Comments}
330 onChange={linkEvent(this, this.handleViewChange)}
335 className={`btn btn-outline-secondary pointer
336 ${this.state.view == PersonDetailsView.Posts && "active"}
341 value={PersonDetailsView.Posts}
342 checked={this.state.view == PersonDetailsView.Posts}
343 onChange={linkEvent(this, this.handleViewChange)}
348 className={`btn btn-outline-secondary pointer
349 ${this.state.view == PersonDetailsView.Saved && "active"}
354 value={PersonDetailsView.Saved}
355 checked={this.state.view == PersonDetailsView.Saved}
356 onChange={linkEvent(this, this.handleViewChange)}
365 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
368 <div className="mb-2">
369 <span className="mr-3">{this.viewRadios()}</span>
371 sort={this.state.sort}
372 onChange={this.handleSortChange}
376 <a href={profileRss} rel={relTags} title="RSS">
377 <Icon icon="rss" classes="text-muted small mx-2" />
379 <link rel="alternate" type="application/atom+xml" href={profileRss} />
383 handleBlockPerson(personId: number) {
385 let blockUserForm = new BlockPerson({
388 auth: auth().unwrap(),
390 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
393 handleUnblockPerson(recipientId: number) {
394 let blockUserForm = new BlockPerson({
395 person_id: recipientId,
397 auth: auth().unwrap(),
399 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
403 return this.state.personRes
404 .map(r => r.person_view)
409 banner={pv.person.banner}
410 icon={pv.person.avatar}
412 <div className="mb-3">
414 <div className="mb-0 d-flex flex-wrap">
416 {pv.person.display_name && (
417 <h5 className="mb-0">{pv.person.display_name}</h5>
419 <ul className="list-inline mb-2">
420 <li className="list-inline-item">
429 {isBanned(pv.person) && (
430 <li className="list-inline-item badge badge-danger">
434 {pv.person.admin && (
435 <li className="list-inline-item badge badge-light">
439 {pv.person.bot_account && (
440 <li className="list-inline-item badge badge-light">
441 {i18n.t("bot_account").toLowerCase()}
447 <div className="flex-grow-1 unselectable pointer mx-2"></div>
448 {!this.amCurrentUser &&
449 UserService.Instance.myUserInfo.isSome() && (
452 className={`d-flex align-self-start btn btn-secondary mr-2 ${
453 !pv.person.matrix_user_id && "invisible"
456 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
458 {i18n.t("send_secure_message")}
462 "d-flex align-self-start btn btn-secondary mr-2"
464 to={`/create_private_message/recipient/${pv.person.id}`}
466 {i18n.t("send_message")}
468 {this.state.personBlocked ? (
471 "d-flex align-self-start btn btn-secondary mr-2"
475 this.handleUnblockPerson
478 {i18n.t("unblock_user")}
483 "d-flex align-self-start btn btn-secondary mr-2"
487 this.handleBlockPerson
490 {i18n.t("block_user")}
498 Some(this.state.siteRes.admins),
501 !isAdmin(Some(this.state.siteRes.admins), pv.person.id) &&
502 !this.state.showBanDialog &&
503 (!isBanned(pv.person) ? (
506 "d-flex align-self-start btn btn-secondary mr-2"
508 onClick={linkEvent(this, this.handleModBanShow)}
509 aria-label={i18n.t("ban")}
511 {capitalizeFirstLetter(i18n.t("ban"))}
516 "d-flex align-self-start btn btn-secondary mr-2"
518 onClick={linkEvent(this, this.handleModBanSubmit)}
519 aria-label={i18n.t("unban")}
521 {capitalizeFirstLetter(i18n.t("unban"))}
525 {pv.person.bio.match({
527 <div className="d-flex align-items-center mb-2">
530 dangerouslySetInnerHTML={mdToHtml(bio)}
537 <ul className="list-inline mb-2">
538 <li className="list-inline-item badge badge-light">
539 {i18n.t("number_of_posts", {
540 count: pv.counts.post_count,
541 formattedCount: numToSI(pv.counts.post_count),
544 <li className="list-inline-item badge badge-light">
545 {i18n.t("number_of_comments", {
546 count: pv.counts.comment_count,
547 formattedCount: numToSI(pv.counts.comment_count),
552 <div className="text-muted">
553 {i18n.t("joined")}{" "}
555 published={pv.person.published}
561 <div className="d-flex align-items-center text-muted mb-2">
563 <span className="ml-2">
564 {i18n.t("cake_day_title")}{" "}
566 .utc(pv.person.published)
568 .format("MMM DD, YYYY")}
580 return this.state.personRes
581 .map(r => r.person_view)
585 {this.state.showBanDialog && (
586 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
587 <div className="form-group row col-12">
589 className="col-form-label"
590 htmlFor="profile-ban-reason"
596 id="profile-ban-reason"
597 className="form-control mr-2"
598 placeholder={i18n.t("reason")}
599 value={toUndefined(this.state.banReason)}
600 onInput={linkEvent(this, this.handleModBanReasonChange)}
602 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
607 id={`mod-ban-expires`}
608 className="form-control mr-2"
609 placeholder={i18n.t("number_of_days")}
610 value={toUndefined(this.state.banExpireDays)}
611 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
613 <div className="form-group">
614 <div className="form-check">
616 className="form-check-input"
617 id="mod-ban-remove-data"
619 checked={this.state.removeData}
622 this.handleModRemoveDataChange
626 className="form-check-label"
627 htmlFor="mod-ban-remove-data"
628 title={i18n.t("remove_content_more")}
630 {i18n.t("remove_content")}
635 {/* TODO hold off on expires until later */}
636 {/* <div class="form-group row"> */}
637 {/* <label class="col-form-label">Expires</label> */}
638 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
640 <div className="form-group row">
643 className="btn btn-secondary mr-2"
644 aria-label={i18n.t("cancel")}
645 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
651 className="btn btn-secondary"
652 aria-label={i18n.t("ban")}
654 {i18n.t("ban")} {pv.person.name}
665 // TODO test this, make sure its good
667 return this.state.personRes
668 .map(r => r.moderates)
671 if (moderates.length > 0) {
672 <div className="card border-secondary mb-3">
673 <div className="card-body">
674 <h5>{i18n.t("moderates")}</h5>
675 <ul className="list-unstyled mb-0">
676 {moderates.map(cmv => (
677 <li key={cmv.community.id}>
678 <CommunityLink community={cmv.community} />
691 return UserService.Instance.myUserInfo
695 if (follows.length > 0) {
696 <div className="card border-secondary mb-3">
697 <div className="card-body">
698 <h5>{i18n.t("subscribed")}</h5>
699 <ul className="list-unstyled mb-0">
700 {follows.map(cfv => (
701 <li key={cfv.community.id}>
702 <CommunityLink community={cfv.community} />
714 updateUrl(paramUpdates: UrlParams) {
715 const page = paramUpdates.page || this.state.page;
716 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
717 const sortStr = paramUpdates.sort || this.state.sort;
719 let typeView = `/u/${this.state.userName}`;
721 this.props.history.push(
722 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
724 this.setState({ loading: true });
725 this.fetchUserData();
728 handlePageChange(page: number) {
729 this.updateUrl({ page: page });
732 handleSortChange(val: SortType) {
733 this.updateUrl({ sort: val, page: 1 });
736 handleViewChange(i: Profile, event: any) {
738 view: PersonDetailsView[Number(event.target.value)],
743 handleModBanShow(i: Profile) {
744 i.setState({ showBanDialog: true });
747 handleModBanReasonChange(i: Profile, event: any) {
748 i.setState({ banReason: event.target.value });
751 handleModBanExpireDaysChange(i: Profile, event: any) {
752 i.setState({ banExpireDays: event.target.value });
755 handleModRemoveDataChange(i: Profile, event: any) {
756 i.setState({ removeData: event.target.checked });
759 handleModBanSubmitCancel(i: Profile, event?: any) {
760 event.preventDefault();
761 i.setState({ 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.setState({ 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.setState({ showBanDialog: false });
792 parseMessage(msg: any) {
793 let op = wsUserOp(msg);
796 toast(i18n.t(msg.error), "danger");
797 if (msg.error == "couldnt_find_that_username_or_email") {
798 this.context.router.history.push("/");
801 } else if (msg.reconnect) {
802 this.fetchUserData();
803 } else if (op == UserOperation.GetPersonDetails) {
804 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
805 // and set the parent state if it is not set or differs
806 // TODO this might need to get abstracted
807 let data = wsJsonToRes<GetPersonDetailsResponse>(
809 GetPersonDetailsResponse
811 this.setState({ personRes: Some(data), loading: false });
812 this.setPersonBlock();
813 restoreScrollPosition(this.context);
814 } else if (op == UserOperation.AddAdmin) {
815 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
816 this.setState(s => ((s.siteRes.admins = data.admins), s));
817 } else if (op == UserOperation.CreateCommentLike) {
818 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
819 createCommentLikeRes(
821 this.state.personRes.map(r => r.comments).unwrapOr([])
823 this.setState(this.state);
825 op == UserOperation.EditComment ||
826 op == UserOperation.DeleteComment ||
827 op == UserOperation.RemoveComment
829 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
832 this.state.personRes.map(r => r.comments).unwrapOr([])
834 this.setState(this.state);
835 } else if (op == UserOperation.CreateComment) {
836 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
837 UserService.Instance.myUserInfo.match({
839 if (data.comment_view.creator.id == mui.local_user_view.person.id) {
840 toast(i18n.t("reply_sent"));
845 } else if (op == UserOperation.SaveComment) {
846 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
849 this.state.personRes.map(r => r.comments).unwrapOr([])
851 this.setState(this.state);
853 op == UserOperation.EditPost ||
854 op == UserOperation.DeletePost ||
855 op == UserOperation.RemovePost ||
856 op == UserOperation.LockPost ||
857 op == UserOperation.StickyPost ||
858 op == UserOperation.SavePost
860 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
863 this.state.personRes.map(r => r.posts).unwrapOr([])
865 this.setState(this.state);
866 } else if (op == UserOperation.CreatePostLike) {
867 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
868 createPostLikeFindRes(
870 this.state.personRes.map(r => r.posts).unwrapOr([])
872 this.setState(this.state);
873 } else if (op == UserOperation.BanPerson) {
874 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
875 this.state.personRes.match({
878 .filter(c => c.creator.id == data.person_view.person.id)
879 .forEach(c => (c.creator.banned = data.banned));
881 .filter(c => c.creator.id == data.person_view.person.id)
882 .forEach(c => (c.creator.banned = data.banned));
883 let pv = res.person_view;
885 if (pv.person.id == data.person_view.person.id) {
886 pv.person.banned = data.banned;
888 this.setState(this.state);
892 } else if (op == UserOperation.BlockPerson) {
893 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
894 updatePersonBlock(data);
895 this.setPersonBlock();
896 this.setState(this.state);
898 op == UserOperation.PurgePerson ||
899 op == UserOperation.PurgePost ||
900 op == UserOperation.PurgeComment ||
901 op == UserOperation.PurgeCommunity
903 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
905 toast(i18n.t("purge_success"));
906 this.context.router.history.push(`/`);