1 import { None, Option, Some } from "@sniptt/monads";
2 import { Component, linkEvent } from "inferno";
3 import { Link } from "inferno-router";
12 GetPersonDetailsResponse,
20 } from "lemmy-js-client";
21 import moment from "moment";
22 import { Subscription } from "rxjs";
23 import { i18n } from "../../i18next";
24 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
25 import { UserService, WebSocketService } from "../../services";
29 capitalizeFirstLetter,
31 createPostLikeFindRes,
44 restoreScrollPosition,
55 import { BannerIconHeader } from "../common/banner-icon-header";
56 import { HtmlTags } from "../common/html-tags";
57 import { Icon, Spinner } from "../common/icon";
58 import { MomentTime } from "../common/moment-time";
59 import { SortSelect } from "../common/sort-select";
60 import { CommunityLink } from "../community/community-link";
61 import { PersonDetails } from "./person-details";
62 import { PersonListing } from "./person-listing";
64 interface ProfileState {
65 personRes: Option<GetPersonDetailsResponse>;
67 view: PersonDetailsView;
71 personBlocked: boolean;
72 banReason: Option<string>;
73 banExpireDays: Option<number>;
74 showBanDialog: boolean;
76 siteRes: GetSiteResponse;
79 interface ProfileProps {
80 view: PersonDetailsView;
83 person_id: number | null;
93 export class Profile extends Component<any, ProfileState> {
94 private isoData = setIsoData(this.context, GetPersonDetailsResponse);
95 private subscription: Subscription;
96 private emptyState: ProfileState = {
98 userName: getUsernameFromProps(this.props),
100 view: Profile.getViewFromProps(this.props.match.view),
101 sort: Profile.getSortTypeFromProps(this.props.match.sort),
102 page: Profile.getPageFromProps(this.props.match.page),
103 personBlocked: false,
104 siteRes: this.isoData.site_res,
105 showBanDialog: false,
111 constructor(props: any, context: any) {
112 super(props, context);
114 this.state = this.emptyState;
115 this.handleSortChange = this.handleSortChange.bind(this);
116 this.handlePageChange = this.handlePageChange.bind(this);
118 this.parseMessage = this.parseMessage.bind(this);
119 this.subscription = wsSubscribe(this.parseMessage);
121 // Only fetch the data if coming from another route
122 if (this.isoData.path == this.context.router.route.match.url) {
123 this.state.personRes = Some(
124 this.isoData.routeData[0] as GetPersonDetailsResponse
126 this.state.loading = false;
128 this.fetchUserData();
131 this.setPersonBlock();
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({
165 this.state.personBlocked = mui.person_blocks
166 .map(a => a.target.id)
167 .includes(res.person_view.person.id);
175 static getViewFromProps(view: string): PersonDetailsView {
176 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
179 static getSortTypeFromProps(sort: string): SortType {
180 return sort ? routeSortTypeToEnum(sort) : SortType.New;
183 static getPageFromProps(page: number): number {
184 return page ? Number(page) : 1;
187 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
188 let pathSplit = req.path.split("/");
190 let username = pathSplit[2];
191 let view = this.getViewFromProps(pathSplit[4]);
192 let sort = Some(this.getSortTypeFromProps(pathSplit[6]));
193 let page = Some(this.getPageFromProps(Number(pathSplit[8])));
195 let form = new GetPersonDetails({
196 username: Some(username),
200 saved_only: Some(view === PersonDetailsView.Saved),
202 limit: Some(fetchLimit),
205 return [req.client.getPersonDetails(form)];
208 componentDidMount() {
212 componentWillUnmount() {
213 this.subscription.unsubscribe();
214 saveScrollPosition(this.context);
217 static getDerivedStateFromProps(props: any): ProfileProps {
219 view: this.getViewFromProps(props.match.params.view),
220 sort: this.getSortTypeFromProps(props.match.params.sort),
221 page: this.getPageFromProps(props.match.params.page),
222 person_id: Number(props.match.params.id) || null,
223 username: props.match.params.username,
227 componentDidUpdate(lastProps: any) {
228 // Necessary if you are on a post and you click another post (same route)
230 lastProps.location.pathname.split("/")[2] !==
231 lastProps.history.location.pathname.split("/")[2]
233 // Couldnt get a refresh working. This does for now.
238 get documentTitle(): string {
239 return this.state.siteRes.site_view.match({
241 this.state.personRes.match({
243 `@${res.person_view.person.name} - ${siteView.site.name}`,
252 <div class="container">
253 {this.state.loading ? (
258 this.state.personRes.match({
261 <div class="col-12 col-md-8">
264 title={this.documentTitle}
265 path={this.context.router.route.match.url}
266 description={res.person_view.person.bio}
267 image={res.person_view.person.avatar}
272 {!this.state.loading && this.selects()}
275 admins={this.state.siteRes.admins}
276 sort={this.state.sort}
277 page={this.state.page}
279 enableDownvotes={enableDownvotes(this.state.siteRes)}
280 enableNsfw={enableNsfw(this.state.siteRes)}
281 view={this.state.view}
282 onPageChange={this.handlePageChange}
286 {!this.state.loading && (
287 <div class="col-12 col-md-4">
289 {this.amCurrentUser && this.follows()}
303 <div class="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 class="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}
410 <div class="mb-0 d-flex flex-wrap">
412 {pv.person.display_name && (
413 <h5 class="mb-0">{pv.person.display_name}</h5>
415 <ul class="list-inline mb-2">
416 <li className="list-inline-item">
425 {isBanned(pv.person) && (
426 <li className="list-inline-item badge badge-danger">
430 {pv.person.admin && (
431 <li className="list-inline-item badge badge-light">
435 {pv.person.bot_account && (
436 <li className="list-inline-item badge badge-light">
437 {i18n.t("bot_account").toLowerCase()}
443 <div className="flex-grow-1 unselectable pointer mx-2"></div>
444 {!this.amCurrentUser &&
445 UserService.Instance.myUserInfo.isSome() && (
448 className={`d-flex align-self-start btn btn-secondary mr-2 ${
449 !pv.person.matrix_user_id && "invisible"
452 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
454 {i18n.t("send_secure_message")}
458 "d-flex align-self-start btn btn-secondary mr-2"
460 to={`/create_private_message/recipient/${pv.person.id}`}
462 {i18n.t("send_message")}
464 {this.state.personBlocked ? (
467 "d-flex align-self-start btn btn-secondary mr-2"
471 this.handleUnblockPerson
474 {i18n.t("unblock_user")}
479 "d-flex align-self-start btn btn-secondary mr-2"
483 this.handleBlockPerson
486 {i18n.t("block_user")}
494 Some(this.state.siteRes.admins),
497 !isAdmin(Some(this.state.siteRes.admins), pv.person.id) &&
498 !this.state.showBanDialog &&
499 (!isBanned(pv.person) ? (
502 "d-flex align-self-start btn btn-secondary mr-2"
504 onClick={linkEvent(this, this.handleModBanShow)}
505 aria-label={i18n.t("ban")}
507 {capitalizeFirstLetter(i18n.t("ban"))}
512 "d-flex align-self-start btn btn-secondary mr-2"
514 onClick={linkEvent(this, this.handleModBanSubmit)}
515 aria-label={i18n.t("unban")}
517 {capitalizeFirstLetter(i18n.t("unban"))}
521 {pv.person.bio.match({
523 <div className="d-flex align-items-center mb-2">
526 dangerouslySetInnerHTML={mdToHtml(bio)}
533 <ul class="list-inline mb-2">
534 <li className="list-inline-item badge badge-light">
535 {i18n.t("number_of_posts", {
536 count: pv.counts.post_count,
537 formattedCount: numToSI(pv.counts.post_count),
540 <li className="list-inline-item badge badge-light">
541 {i18n.t("number_of_comments", {
542 count: pv.counts.comment_count,
543 formattedCount: numToSI(pv.counts.comment_count),
548 <div class="text-muted">
549 {i18n.t("joined")}{" "}
551 published={pv.person.published}
557 <div className="d-flex align-items-center text-muted mb-2">
559 <span className="ml-2">
560 {i18n.t("cake_day_title")}{" "}
562 .utc(pv.person.published)
564 .format("MMM DD, YYYY")}
576 return this.state.personRes
577 .map(r => r.person_view)
581 {this.state.showBanDialog && (
582 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
583 <div class="form-group row col-12">
584 <label class="col-form-label" htmlFor="profile-ban-reason">
589 id="profile-ban-reason"
590 class="form-control mr-2"
591 placeholder={i18n.t("reason")}
592 value={toUndefined(this.state.banReason)}
593 onInput={linkEvent(this, this.handleModBanReasonChange)}
595 <label class="col-form-label" htmlFor={`mod-ban-expires`}>
600 id={`mod-ban-expires`}
601 class="form-control mr-2"
602 placeholder={i18n.t("number_of_days")}
603 value={toUndefined(this.state.banExpireDays)}
604 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
606 <div class="form-group">
607 <div class="form-check">
609 class="form-check-input"
610 id="mod-ban-remove-data"
612 checked={this.state.removeData}
615 this.handleModRemoveDataChange
619 class="form-check-label"
620 htmlFor="mod-ban-remove-data"
621 title={i18n.t("remove_content_more")}
623 {i18n.t("remove_content")}
628 {/* TODO hold off on expires until later */}
629 {/* <div class="form-group row"> */}
630 {/* <label class="col-form-label">Expires</label> */}
631 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
633 <div class="form-group row">
636 class="btn btn-secondary mr-2"
637 aria-label={i18n.t("cancel")}
638 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
644 class="btn btn-secondary"
645 aria-label={i18n.t("ban")}
647 {i18n.t("ban")} {pv.person.name}
658 // TODO test this, make sure its good
660 return this.state.personRes
661 .map(r => r.moderates)
664 if (moderates.length > 0) {
665 <div class="card border-secondary mb-3">
666 <div class="card-body">
667 <h5>{i18n.t("moderates")}</h5>
668 <ul class="list-unstyled mb-0">
669 {moderates.map(cmv => (
671 <CommunityLink community={cmv.community} />
684 return UserService.Instance.myUserInfo
688 if (follows.length > 0) {
689 <div class="card border-secondary mb-3">
690 <div class="card-body">
691 <h5>{i18n.t("subscribed")}</h5>
692 <ul class="list-unstyled mb-0">
693 {follows.map(cfv => (
695 <CommunityLink community={cfv.community} />
707 updateUrl(paramUpdates: UrlParams) {
708 const page = paramUpdates.page || this.state.page;
709 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
710 const sortStr = paramUpdates.sort || this.state.sort;
712 let typeView = `/u/${this.state.userName}`;
714 this.props.history.push(
715 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
717 this.state.loading = true;
718 this.setState(this.state);
719 this.fetchUserData();
722 handlePageChange(page: number) {
723 this.updateUrl({ page: page });
726 handleSortChange(val: SortType) {
727 this.updateUrl({ sort: val, page: 1 });
730 handleViewChange(i: Profile, event: any) {
732 view: PersonDetailsView[Number(event.target.value)],
737 handleModBanShow(i: Profile) {
738 i.state.showBanDialog = true;
742 handleModBanReasonChange(i: Profile, event: any) {
743 i.state.banReason = event.target.value;
747 handleModBanExpireDaysChange(i: Profile, event: any) {
748 i.state.banExpireDays = event.target.value;
752 handleModRemoveDataChange(i: Profile, event: any) {
753 i.state.removeData = event.target.checked;
757 handleModBanSubmitCancel(i: Profile, event?: any) {
758 event.preventDefault();
759 i.state.showBanDialog = false;
763 handleModBanSubmit(i: Profile, event?: any) {
764 if (event) event.preventDefault();
767 .map(r => r.person_view.person)
770 // If its an unban, restore all their data
771 let ban = !person.banned;
773 i.state.removeData = false;
775 let form = new BanPerson({
776 person_id: person.id,
778 remove_data: Some(i.state.removeData),
779 reason: i.state.banReason,
780 expires: i.state.banExpireDays.map(futureDaysToUnixTime),
781 auth: auth().unwrap(),
783 WebSocketService.Instance.send(wsClient.banPerson(form));
785 i.state.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.state.personRes = Some(data);
812 this.state.loading = false;
813 this.setPersonBlock();
814 this.setState(this.state);
815 restoreScrollPosition(this.context);
816 } else if (op == UserOperation.AddAdmin) {
817 let data = wsJsonToRes<AddAdminResponse>(msg, AddAdminResponse);
818 this.state.siteRes.admins = data.admins;
819 this.setState(this.state);
820 } else if (op == UserOperation.CreateCommentLike) {
821 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
822 createCommentLikeRes(
824 this.state.personRes.map(r => r.comments).unwrapOr([])
826 this.setState(this.state);
828 op == UserOperation.EditComment ||
829 op == UserOperation.DeleteComment ||
830 op == UserOperation.RemoveComment
832 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
835 this.state.personRes.map(r => r.comments).unwrapOr([])
837 this.setState(this.state);
838 } else if (op == UserOperation.CreateComment) {
839 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
840 UserService.Instance.myUserInfo.match({
842 if (data.comment_view.creator.id == mui.local_user_view.person.id) {
843 toast(i18n.t("reply_sent"));
848 } else if (op == UserOperation.SaveComment) {
849 let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
852 this.state.personRes.map(r => r.comments).unwrapOr([])
854 this.setState(this.state);
856 op == UserOperation.EditPost ||
857 op == UserOperation.DeletePost ||
858 op == UserOperation.RemovePost ||
859 op == UserOperation.LockPost ||
860 op == UserOperation.StickyPost ||
861 op == UserOperation.SavePost
863 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
866 this.state.personRes.map(r => r.posts).unwrapOr([])
868 this.setState(this.state);
869 } else if (op == UserOperation.CreatePostLike) {
870 let data = wsJsonToRes<PostResponse>(msg, PostResponse);
871 createPostLikeFindRes(
873 this.state.personRes.map(r => r.posts).unwrapOr([])
875 this.setState(this.state);
876 } else if (op == UserOperation.BanPerson) {
877 let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse);
878 this.state.personRes.match({
881 .filter(c => c.creator.id == data.person_view.person.id)
882 .forEach(c => (c.creator.banned = data.banned));
884 .filter(c => c.creator.id == data.person_view.person.id)
885 .forEach(c => (c.creator.banned = data.banned));
886 let pv = res.person_view;
888 if (pv.person.id == data.person_view.person.id) {
889 pv.person.banned = data.banned;
891 this.setState(this.state);
895 } else if (op == UserOperation.BlockPerson) {
896 let data = wsJsonToRes<BlockPersonResponse>(msg, BlockPersonResponse);
897 updatePersonBlock(data);
898 this.setPersonBlock();
899 this.setState(this.state);