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();
135 let form = new GetPersonDetails({
136 username: Some(this.state.userName),
139 sort: Some(this.state.sort),
140 saved_only: Some(this.state.view === PersonDetailsView.Saved),
141 page: Some(this.state.page),
142 limit: Some(fetchLimit),
143 auth: auth(false).ok(),
145 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
148 get amCurrentUser() {
149 return UserService.Instance.myUserInfo.match({
151 this.state.personRes.match({
153 mui.local_user_view.person.id == res.person_view.person.id,
161 UserService.Instance.myUserInfo.match({
163 this.state.personRes.match({
166 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() {
210 this.setPersonBlock();
214 componentWillUnmount() {
215 this.subscription.unsubscribe();
216 saveScrollPosition(this.context);
219 static getDerivedStateFromProps(props: any): ProfileProps {
221 view: this.getViewFromProps(props.match.params.view),
222 sort: this.getSortTypeFromProps(props.match.params.sort),
223 page: this.getPageFromProps(props.match.params.page),
224 person_id: Number(props.match.params.id) || null,
225 username: props.match.params.username,
229 componentDidUpdate(lastProps: any) {
230 // Necessary if you are on a post and you click another post (same route)
232 lastProps.location.pathname.split("/")[2] !==
233 lastProps.history.location.pathname.split("/")[2]
235 // Couldnt get a refresh working. This does for now.
240 get documentTitle(): string {
241 return this.state.personRes.match({
243 `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`,
250 <div className="container-lg">
251 {this.state.loading ? (
256 this.state.personRes.match({
258 <div className="row">
259 <div className="col-12 col-md-8">
262 title={this.documentTitle}
263 path={this.context.router.route.match.url}
264 description={res.person_view.person.bio}
265 image={res.person_view.person.avatar}
270 {!this.state.loading && this.selects()}
273 admins={this.state.siteRes.admins}
274 sort={this.state.sort}
275 page={this.state.page}
277 enableDownvotes={enableDownvotes(this.state.siteRes)}
278 enableNsfw={enableNsfw(this.state.siteRes)}
279 view={this.state.view}
280 onPageChange={this.handlePageChange}
281 allLanguages={this.state.siteRes.all_languages}
282 siteLanguages={this.state.siteRes.discussion_languages}
286 {!this.state.loading && (
287 <div className="col-12 col-md-4">
289 {this.amCurrentUser && this.follows()}
303 <div className="btn-group btn-group-toggle flex-wrap mb-2">
305 className={`btn btn-outline-secondary pointer
306 ${this.state.view == PersonDetailsView.Overview && "active"}
311 value={PersonDetailsView.Overview}
312 checked={this.state.view === PersonDetailsView.Overview}
313 onChange={linkEvent(this, this.handleViewChange)}
318 className={`btn btn-outline-secondary pointer
319 ${this.state.view == PersonDetailsView.Comments && "active"}
324 value={PersonDetailsView.Comments}
325 checked={this.state.view == PersonDetailsView.Comments}
326 onChange={linkEvent(this, this.handleViewChange)}
331 className={`btn btn-outline-secondary pointer
332 ${this.state.view == PersonDetailsView.Posts && "active"}
337 value={PersonDetailsView.Posts}
338 checked={this.state.view == PersonDetailsView.Posts}
339 onChange={linkEvent(this, this.handleViewChange)}
344 className={`btn btn-outline-secondary pointer
345 ${this.state.view == PersonDetailsView.Saved && "active"}
350 value={PersonDetailsView.Saved}
351 checked={this.state.view == PersonDetailsView.Saved}
352 onChange={linkEvent(this, this.handleViewChange)}
361 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
364 <div className="mb-2">
365 <span className="mr-3">{this.viewRadios()}</span>
367 sort={this.state.sort}
368 onChange={this.handleSortChange}
372 <a href={profileRss} rel={relTags} title="RSS">
373 <Icon icon="rss" classes="text-muted small mx-2" />
375 <link rel="alternate" type="application/atom+xml" href={profileRss} />
379 handleBlockPerson(personId: number) {
381 let blockUserForm = new BlockPerson({
384 auth: auth().unwrap(),
386 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
389 handleUnblockPerson(recipientId: number) {
390 let blockUserForm = new BlockPerson({
391 person_id: recipientId,
393 auth: auth().unwrap(),
395 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
399 return this.state.personRes
400 .map(r => r.person_view)
405 banner={pv.person.banner}
406 icon={pv.person.avatar}
408 <div className="mb-3">
410 <div className="mb-0 d-flex flex-wrap">
412 {pv.person.display_name.match({
413 some: displayName => (
414 <h5 className="mb-0">{displayName}</h5>
418 <ul className="list-inline mb-2">
419 <li className="list-inline-item">
428 {isBanned(pv.person) && (
429 <li className="list-inline-item badge badge-danger">
433 {pv.person.deleted && (
434 <li className="list-inline-item badge badge-danger">
438 {pv.person.admin && (
439 <li className="list-inline-item badge badge-light">
443 {pv.person.bot_account && (
444 <li className="list-inline-item badge badge-light">
445 {i18n.t("bot_account").toLowerCase()}
451 <div className="flex-grow-1 unselectable pointer mx-2"></div>
452 {!this.amCurrentUser &&
453 UserService.Instance.myUserInfo.isSome() && (
456 className={`d-flex align-self-start btn btn-secondary mr-2 ${
457 !pv.person.matrix_user_id && "invisible"
460 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
462 {i18n.t("send_secure_message")}
466 "d-flex align-self-start btn btn-secondary mr-2"
468 to={`/create_private_message/recipient/${pv.person.id}`}
470 {i18n.t("send_message")}
472 {this.state.personBlocked ? (
475 "d-flex align-self-start btn btn-secondary mr-2"
479 this.handleUnblockPerson
482 {i18n.t("unblock_user")}
487 "d-flex align-self-start btn btn-secondary mr-2"
491 this.handleBlockPerson
494 {i18n.t("block_user")}
502 Some(this.state.siteRes.admins),
505 !isAdmin(Some(this.state.siteRes.admins), pv.person.id) &&
506 !this.state.showBanDialog &&
507 (!isBanned(pv.person) ? (
510 "d-flex align-self-start btn btn-secondary mr-2"
512 onClick={linkEvent(this, this.handleModBanShow)}
513 aria-label={i18n.t("ban")}
515 {capitalizeFirstLetter(i18n.t("ban"))}
520 "d-flex align-self-start btn btn-secondary mr-2"
522 onClick={linkEvent(this, this.handleModBanSubmit)}
523 aria-label={i18n.t("unban")}
525 {capitalizeFirstLetter(i18n.t("unban"))}
529 {pv.person.bio.match({
531 <div className="d-flex align-items-center mb-2">
534 dangerouslySetInnerHTML={mdToHtml(bio)}
541 <ul className="list-inline mb-2">
542 <li className="list-inline-item badge badge-light">
543 {i18n.t("number_of_posts", {
544 count: pv.counts.post_count,
545 formattedCount: numToSI(pv.counts.post_count),
548 <li className="list-inline-item badge badge-light">
549 {i18n.t("number_of_comments", {
550 count: pv.counts.comment_count,
551 formattedCount: numToSI(pv.counts.comment_count),
556 <div className="text-muted">
557 {i18n.t("joined")}{" "}
559 published={pv.person.published}
565 <div className="d-flex align-items-center text-muted mb-2">
567 <span className="ml-2">
568 {i18n.t("cake_day_title")}{" "}
570 .utc(pv.person.published)
572 .format("MMM DD, YYYY")}
584 return this.state.personRes
585 .map(r => r.person_view)
589 {this.state.showBanDialog && (
590 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
591 <div className="form-group row col-12">
593 className="col-form-label"
594 htmlFor="profile-ban-reason"
600 id="profile-ban-reason"
601 className="form-control mr-2"
602 placeholder={i18n.t("reason")}
603 value={toUndefined(this.state.banReason)}
604 onInput={linkEvent(this, this.handleModBanReasonChange)}
606 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
611 id={`mod-ban-expires`}
612 className="form-control mr-2"
613 placeholder={i18n.t("number_of_days")}
614 value={toUndefined(this.state.banExpireDays)}
615 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
617 <div className="form-group">
618 <div className="form-check">
620 className="form-check-input"
621 id="mod-ban-remove-data"
623 checked={this.state.removeData}
626 this.handleModRemoveDataChange
630 className="form-check-label"
631 htmlFor="mod-ban-remove-data"
632 title={i18n.t("remove_content_more")}
634 {i18n.t("remove_content")}
639 {/* TODO hold off on expires until later */}
640 {/* <div class="form-group row"> */}
641 {/* <label class="col-form-label">Expires</label> */}
642 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
644 <div className="form-group row">
647 className="btn btn-secondary mr-2"
648 aria-label={i18n.t("cancel")}
649 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
655 className="btn btn-secondary"
656 aria-label={i18n.t("ban")}
658 {i18n.t("ban")} {pv.person.name}
670 return this.state.personRes
671 .map(r => r.moderates)
674 if (moderates.length > 0) {
676 <div className="card border-secondary mb-3">
677 <div className="card-body">
678 <h5>{i18n.t("moderates")}</h5>
679 <ul className="list-unstyled mb-0">
680 {moderates.map(cmv => (
681 <li key={cmv.community.id}>
682 <CommunityLink community={cmv.community} />
698 return UserService.Instance.myUserInfo
702 if (follows.length > 0) {
704 <div className="card border-secondary mb-3">
705 <div className="card-body">
706 <h5>{i18n.t("subscribed")}</h5>
707 <ul className="list-unstyled mb-0">
708 {follows.map(cfv => (
709 <li key={cfv.community.id}>
710 <CommunityLink community={cfv.community} />
725 updateUrl(paramUpdates: UrlParams) {
726 const page = paramUpdates.page || this.state.page;
727 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
728 const sortStr = paramUpdates.sort || this.state.sort;
730 let typeView = `/u/${this.state.userName}`;
732 this.props.history.push(
733 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
735 this.setState({ loading: true });
736 this.fetchUserData();
739 handlePageChange(page: number) {
740 this.updateUrl({ page: page });
743 handleSortChange(val: SortType) {
744 this.updateUrl({ sort: val, page: 1 });
747 handleViewChange(i: Profile, event: any) {
749 view: PersonDetailsView[Number(event.target.value)],
754 handleModBanShow(i: Profile) {
755 i.setState({ showBanDialog: true });
758 handleModBanReasonChange(i: Profile, event: any) {
759 i.setState({ banReason: event.target.value });
762 handleModBanExpireDaysChange(i: Profile, event: any) {
763 i.setState({ banExpireDays: event.target.value });
766 handleModRemoveDataChange(i: Profile, event: any) {
767 i.setState({ removeData: event.target.checked });
770 handleModBanSubmitCancel(i: Profile, event?: any) {
771 event.preventDefault();
772 i.setState({ showBanDialog: false });
775 handleModBanSubmit(i: Profile, event?: any) {
776 if (event) event.preventDefault();
779 .map(r => r.person_view.person)
782 // If its an unban, restore all their data
783 let ban = !person.banned;
785 i.setState({ removeData: false });
787 let form = new BanPerson({
788 person_id: person.id,
790 remove_data: Some(i.state.removeData),
791 reason: i.state.banReason,
792 expires: i.state.banExpireDays.map(futureDaysToUnixTime),
793 auth: auth().unwrap(),
795 WebSocketService.Instance.send(wsClient.banPerson(form));
797 i.setState({ showBanDialog: false });
803 parseMessage(msg: any) {
804 let op = wsUserOp(msg);
807 toast(i18n.t(msg.error), "danger");
808 if (msg.error == "couldnt_find_that_username_or_email") {
809 this.context.router.history.push("/");
812 } else if (msg.reconnect) {
813 this.fetchUserData();
814 } else if (op == UserOperation.GetPersonDetails) {
815 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
816 // and set the parent state if it is not set or differs
817 // TODO this might need to get abstracted
818 let data = wsJsonToRes<GetPersonDetailsResponse>(
820 GetPersonDetailsResponse
822 this.setState({ personRes: Some(data), loading: false });
823 this.setPersonBlock();
824 restoreScrollPosition(this.context);
825 } else if (op == UserOperation.AddAdmin) {
826 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
827 this.setState(s => ((s.siteRes.admins = data.admins), s));
828 } else if (op == UserOperation.CreateCommentLike) {
829 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
830 createCommentLikeRes(
832 this.state.personRes.map(r => r.comments).unwrapOr([])
834 this.setState(this.state);
836 op == UserOperation.EditComment ||
837 op == UserOperation.DeleteComment ||
838 op == UserOperation.RemoveComment
840 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
843 this.state.personRes.map(r => r.comments).unwrapOr([])
845 this.setState(this.state);
846 } else if (op == UserOperation.CreateComment) {
847 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
848 UserService.Instance.myUserInfo.match({
850 if (data.comment_view.creator.id == mui.local_user_view.person.id) {
851 toast(i18n.t("reply_sent"));
856 } else if (op == UserOperation.SaveComment) {
857 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
860 this.state.personRes.map(r => r.comments).unwrapOr([])
862 this.setState(this.state);
864 op == UserOperation.EditPost ||
865 op == UserOperation.DeletePost ||
866 op == UserOperation.RemovePost ||
867 op == UserOperation.LockPost ||
868 op == UserOperation.FeaturePost ||
869 op == UserOperation.SavePost
871 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
874 this.state.personRes.map(r => r.posts).unwrapOr([])
876 this.setState(this.state);
877 } else if (op == UserOperation.CreatePostLike) {
878 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
879 createPostLikeFindRes(
881 this.state.personRes.map(r => r.posts).unwrapOr([])
883 this.setState(this.state);
884 } else if (op == UserOperation.BanPerson) {
885 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
886 this.state.personRes.match({
889 .filter(c => c.creator.id == data.person_view.person.id)
890 .forEach(c => (c.creator.banned = data.banned));
892 .filter(c => c.creator.id == data.person_view.person.id)
893 .forEach(c => (c.creator.banned = data.banned));
894 let pv = res.person_view;
896 if (pv.person.id == data.person_view.person.id) {
897 pv.person.banned = data.banned;
899 this.setState(this.state);
903 } else if (op == UserOperation.BlockPerson) {
904 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
905 updatePersonBlock(data);
906 this.setPersonBlock();
907 this.setState(this.state);
909 op == UserOperation.PurgePerson ||
910 op == UserOperation.PurgePost ||
911 op == UserOperation.PurgeComment ||
912 op == UserOperation.PurgeCommunity
914 let data = wsJsonToRes<PurgeItemResponse>(msg, PurgeItemResponse);
916 toast(i18n.t("purge_success"));
917 this.context.router.history.push(`/`);