1 import classNames from "classnames";
2 import { NoOptionI18nKeys } from "i18next";
3 import { Component, linkEvent } from "inferno";
4 import { Link } from "inferno-router";
5 import { RouteComponentProps } from "inferno-router/dist/Route";
14 CommunityModeratorView,
16 GetPersonDetailsResponse,
24 } from "lemmy-js-client";
25 import moment from "moment";
26 import { Subscription } from "rxjs";
27 import { i18n } from "../../i18next";
28 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
29 import { UserService, WebSocketService } from "../../services";
33 capitalizeFirstLetter,
35 createPostLikeFindRes,
51 restoreScrollPosition,
61 import { BannerIconHeader } from "../common/banner-icon-header";
62 import { HtmlTags } from "../common/html-tags";
63 import { Icon, Spinner } from "../common/icon";
64 import { MomentTime } from "../common/moment-time";
65 import { SortSelect } from "../common/sort-select";
66 import { CommunityLink } from "../community/community-link";
67 import { PersonDetails } from "./person-details";
68 import { PersonListing } from "./person-listing";
70 interface ProfileState {
71 personRes?: GetPersonDetailsResponse;
73 personBlocked: boolean;
75 banExpireDays?: number;
76 showBanDialog: boolean;
78 siteRes: GetSiteResponse;
81 interface ProfileProps {
82 view: PersonDetailsView;
87 function getProfileQueryParams() {
88 return getQueryParams<ProfileProps>({
89 view: getViewFromProps,
90 page: getPageFromString,
91 sort: getSortTypeFromQuery,
95 function getSortTypeFromQuery(sort?: string): SortType {
96 return sort ? (sort as SortType) : "New";
99 function getViewFromProps(view?: string): PersonDetailsView {
101 ? PersonDetailsView[view] ?? PersonDetailsView.Overview
102 : PersonDetailsView.Overview;
105 function toggleBlockPerson(recipientId: number, block: boolean) {
106 const auth = myAuth();
109 const blockUserForm: BlockPerson = {
110 person_id: recipientId,
115 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
119 const handleUnblockPerson = (personId: number) =>
120 toggleBlockPerson(personId, false);
122 const handleBlockPerson = (personId: number) =>
123 toggleBlockPerson(personId, true);
125 const getCommunitiesListing = (
126 translationKey: NoOptionI18nKeys,
127 communityViews?: { community: Community }[]
130 communityViews.length > 0 && (
131 <div className="card border-secondary mb-3">
132 <div className="card-body">
133 <h5>{i18n.t(translationKey)}</h5>
134 <ul className="list-unstyled mb-0">
135 {communityViews.map(({ community }) => (
136 <li key={community.id}>
137 <CommunityLink community={community} />
145 const Moderates = ({ moderates }: { moderates?: CommunityModeratorView[] }) =>
146 getCommunitiesListing("moderates", moderates);
148 const Follows = () =>
149 getCommunitiesListing("subscribed", UserService.Instance.myUserInfo?.follows);
151 export class Profile extends Component<
152 RouteComponentProps<{ username: string }>,
155 private isoData = setIsoData(this.context);
156 private subscription?: Subscription;
157 state: ProfileState = {
159 personBlocked: false,
160 siteRes: this.isoData.site_res,
161 showBanDialog: false,
165 constructor(props: RouteComponentProps<{ username: string }>, context: any) {
166 super(props, context);
168 this.handleSortChange = this.handleSortChange.bind(this);
169 this.handlePageChange = this.handlePageChange.bind(this);
171 this.parseMessage = this.parseMessage.bind(this);
172 this.subscription = wsSubscribe(this.parseMessage);
174 // Only fetch the data if coming from another route
175 if (this.isoData.path === this.context.router.route.match.url) {
178 personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
182 this.fetchUserData();
187 const { page, sort, view } = getProfileQueryParams();
189 const form: GetPersonDetails = {
190 username: this.props.match.params.username,
192 saved_only: view === PersonDetailsView.Saved,
198 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
201 get amCurrentUser() {
203 UserService.Instance.myUserInfo?.local_user_view.person.id ===
204 this.state.personRes?.person_view.person.id
209 const mui = UserService.Instance.myUserInfo;
210 const res = this.state.personRes;
214 personBlocked: mui.person_blocks.some(
215 ({ target: { id } }) => id === res.person_view.person.id
221 static fetchInitialData({
224 query: { page, sort, view: urlView },
226 }: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<any>[] {
227 const pathSplit = path.split("/");
229 const username = pathSplit[2];
230 const view = getViewFromProps(urlView);
232 const form: GetPersonDetails = {
234 sort: getSortTypeFromQuery(sort),
235 saved_only: view === PersonDetailsView.Saved,
236 page: getPageFromString(page),
241 return [client.getPersonDetails(form)];
244 componentDidMount() {
245 this.setPersonBlock();
249 componentWillUnmount() {
250 this.subscription?.unsubscribe();
251 saveScrollPosition(this.context);
254 get documentTitle(): string {
255 const res = this.state.personRes;
257 ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
262 const { personRes, loading, siteRes } = this.state;
263 const { page, sort, view } = getProfileQueryParams();
266 <div className="container-lg">
273 <div className="row">
274 <div className="col-12 col-md-8">
276 title={this.documentTitle}
277 path={this.context.router.route.match.url}
278 description={personRes.person_view.person.bio}
279 image={personRes.person_view.person.avatar}
289 personRes={personRes}
290 admins={siteRes.admins}
294 enableDownvotes={enableDownvotes(siteRes)}
295 enableNsfw={enableNsfw(siteRes)}
297 onPageChange={this.handlePageChange}
298 allLanguages={siteRes.all_languages}
299 siteLanguages={siteRes.discussion_languages}
303 <div className="col-12 col-md-4">
304 <Moderates moderates={personRes.moderates} />
305 {this.amCurrentUser && <Follows />}
316 <div className="btn-group btn-group-toggle flex-wrap mb-2">
317 {this.getRadio(PersonDetailsView.Overview)}
318 {this.getRadio(PersonDetailsView.Comments)}
319 {this.getRadio(PersonDetailsView.Posts)}
320 {this.amCurrentUser && this.getRadio(PersonDetailsView.Saved)}
325 getRadio(view: PersonDetailsView) {
326 const { view: urlView } = getProfileQueryParams();
327 const active = view === urlView;
331 className={classNames("btn btn-outline-secondary pointer", {
339 onChange={linkEvent(this, this.handleViewChange)}
341 {i18n.t(view.toLowerCase() as NoOptionI18nKeys)}
347 const { sort } = getProfileQueryParams();
348 const { username } = this.props.match.params;
350 const profileRss = `/feeds/u/${username}.xml?sort=${sort}`;
353 <div className="mb-2">
354 <span className="mr-3">{this.viewRadios}</span>
357 onChange={this.handleSortChange}
361 <a href={profileRss} rel={relTags} title="RSS">
362 <Icon icon="rss" classes="text-muted small mx-2" />
364 <link rel="alternate" type="application/atom+xml" href={profileRss} />
370 const pv = this.state.personRes?.person_view;
380 {!isBanned(pv.person) && (
382 banner={pv.person.banner}
383 icon={pv.person.avatar}
386 <div className="mb-3">
388 <div className="mb-0 d-flex flex-wrap">
390 {pv.person.display_name && (
391 <h5 className="mb-0">{pv.person.display_name}</h5>
393 <ul className="list-inline mb-2">
394 <li className="list-inline-item">
403 {isBanned(pv.person) && (
404 <li className="list-inline-item badge badge-danger">
408 {pv.person.deleted && (
409 <li className="list-inline-item badge badge-danger">
413 {pv.person.admin && (
414 <li className="list-inline-item badge badge-light">
418 {pv.person.bot_account && (
419 <li className="list-inline-item badge badge-light">
420 {i18n.t("bot_account").toLowerCase()}
426 <div className="flex-grow-1 unselectable pointer mx-2"></div>
427 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
430 className={`d-flex align-self-start btn btn-secondary mr-2 ${
431 !pv.person.matrix_user_id && "invisible"
434 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
436 {i18n.t("send_secure_message")}
440 "d-flex align-self-start btn btn-secondary mr-2"
442 to={`/create_private_message/${pv.person.id}`}
444 {i18n.t("send_message")}
449 "d-flex align-self-start btn btn-secondary mr-2"
451 onClick={linkEvent(pv.person.id, handleUnblockPerson)}
453 {i18n.t("unblock_user")}
458 "d-flex align-self-start btn btn-secondary mr-2"
460 onClick={linkEvent(pv.person.id, handleBlockPerson)}
462 {i18n.t("block_user")}
468 {canMod(pv.person.id, undefined, admins) &&
469 !isAdmin(pv.person.id, admins) &&
471 (!isBanned(pv.person) ? (
474 "d-flex align-self-start btn btn-secondary mr-2"
476 onClick={linkEvent(this, this.handleModBanShow)}
477 aria-label={i18n.t("ban")}
479 {capitalizeFirstLetter(i18n.t("ban"))}
484 "d-flex align-self-start btn btn-secondary mr-2"
486 onClick={linkEvent(this, this.handleModBanSubmit)}
487 aria-label={i18n.t("unban")}
489 {capitalizeFirstLetter(i18n.t("unban"))}
494 <div className="d-flex align-items-center mb-2">
497 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
502 <ul className="list-inline mb-2">
503 <li className="list-inline-item badge badge-light">
504 {i18n.t("number_of_posts", {
505 count: Number(pv.counts.post_count),
506 formattedCount: numToSI(pv.counts.post_count),
509 <li className="list-inline-item badge badge-light">
510 {i18n.t("number_of_comments", {
511 count: Number(pv.counts.comment_count),
512 formattedCount: numToSI(pv.counts.comment_count),
517 <div className="text-muted">
518 {i18n.t("joined")}{" "}
520 published={pv.person.published}
525 <div className="d-flex align-items-center text-muted mb-2">
527 <span className="ml-2">
528 {i18n.t("cake_day_title")}{" "}
530 .utc(pv.person.published)
532 .format("MMM DD, YYYY")}
535 {!UserService.Instance.myUserInfo && (
536 <div className="alert alert-info" role="alert">
537 {i18n.t("profile_not_logged_in_alert")}
548 const pv = this.state.personRes?.person_view;
549 const { showBanDialog } = this.state;
555 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
556 <div className="form-group row col-12">
557 <label className="col-form-label" htmlFor="profile-ban-reason">
562 id="profile-ban-reason"
563 className="form-control mr-2"
564 placeholder={i18n.t("reason")}
565 value={this.state.banReason}
566 onInput={linkEvent(this, this.handleModBanReasonChange)}
568 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
573 id={`mod-ban-expires`}
574 className="form-control mr-2"
575 placeholder={i18n.t("number_of_days")}
576 value={this.state.banExpireDays}
577 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
579 <div className="form-group">
580 <div className="form-check">
582 className="form-check-input"
583 id="mod-ban-remove-data"
585 checked={this.state.removeData}
586 onChange={linkEvent(this, this.handleModRemoveDataChange)}
589 className="form-check-label"
590 htmlFor="mod-ban-remove-data"
591 title={i18n.t("remove_content_more")}
593 {i18n.t("remove_content")}
598 {/* TODO hold off on expires until later */}
599 {/* <div class="form-group row"> */}
600 {/* <label class="col-form-label">Expires</label> */}
601 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
603 <div className="form-group row">
606 className="btn btn-secondary mr-2"
607 aria-label={i18n.t("cancel")}
608 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
614 className="btn btn-secondary"
615 aria-label={i18n.t("ban")}
617 {i18n.t("ban")} {pv.person.name}
627 updateUrl({ page, sort, view }: Partial<ProfileProps>) {
632 } = getProfileQueryParams();
634 const queryParams: QueryParams<ProfileProps> = {
635 page: (page ?? urlPage).toString(),
636 sort: sort ?? urlSort,
637 view: view ?? urlView,
640 const { username } = this.props.match.params;
642 this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
644 this.setState({ loading: true });
645 this.fetchUserData();
648 handlePageChange(page: number) {
649 this.updateUrl({ page });
652 handleSortChange(sort: SortType) {
653 this.updateUrl({ sort, page: 1 });
656 handleViewChange(i: Profile, event: any) {
658 view: PersonDetailsView[event.target.value],
663 handleModBanShow(i: Profile) {
664 i.setState({ showBanDialog: true });
667 handleModBanReasonChange(i: Profile, event: any) {
668 i.setState({ banReason: event.target.value });
671 handleModBanExpireDaysChange(i: Profile, event: any) {
672 i.setState({ banExpireDays: event.target.value });
675 handleModRemoveDataChange(i: Profile, event: any) {
676 i.setState({ removeData: event.target.checked });
679 handleModBanSubmitCancel(i: Profile, event?: any) {
680 event.preventDefault();
681 i.setState({ showBanDialog: false });
684 handleModBanSubmit(i: Profile, event?: any) {
685 if (event) event.preventDefault();
686 const { personRes, removeData, banReason, banExpireDays } = i.state;
688 const person = personRes?.person_view.person;
689 const auth = myAuth();
691 if (person && auth) {
692 const ban = !person.banned;
694 // If its an unban, restore all their data
696 i.setState({ removeData: false });
699 const form: BanPerson = {
700 person_id: person.id,
702 remove_data: removeData,
704 expires: futureDaysToUnixTime(banExpireDays),
707 WebSocketService.Instance.send(wsClient.banPerson(form));
709 i.setState({ showBanDialog: false });
713 parseMessage(msg: any) {
714 const op = wsUserOp(msg);
718 toast(i18n.t(msg.error), "danger");
720 if (msg.error === "couldnt_find_that_username_or_email") {
721 this.context.router.history.push("/");
723 } else if (msg.reconnect) {
724 this.fetchUserData();
727 case UserOperation.GetPersonDetails: {
728 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
729 // and set the parent state if it is not set or differs
730 // TODO this might need to get abstracted
731 const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
732 this.setState({ personRes: data, loading: false });
733 this.setPersonBlock();
734 restoreScrollPosition(this.context);
739 case UserOperation.AddAdmin: {
740 const { admins } = wsJsonToRes<AddAdminResponse>(msg);
741 this.setState(s => ((s.siteRes.admins = admins), s));
746 case UserOperation.CreateCommentLike: {
747 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
748 createCommentLikeRes(comment_view, this.state.personRes?.comments);
749 this.setState(this.state);
754 case UserOperation.EditComment:
755 case UserOperation.DeleteComment:
756 case UserOperation.RemoveComment: {
757 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
758 editCommentRes(comment_view, this.state.personRes?.comments);
759 this.setState(this.state);
764 case UserOperation.CreateComment: {
769 } = wsJsonToRes<CommentResponse>(msg);
770 const mui = UserService.Instance.myUserInfo;
772 if (id === mui?.local_user_view.person.id) {
773 toast(i18n.t("reply_sent"));
779 case UserOperation.SaveComment: {
780 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
781 saveCommentRes(comment_view, this.state.personRes?.comments);
782 this.setState(this.state);
787 case UserOperation.EditPost:
788 case UserOperation.DeletePost:
789 case UserOperation.RemovePost:
790 case UserOperation.LockPost:
791 case UserOperation.FeaturePost:
792 case UserOperation.SavePost: {
793 const { post_view } = wsJsonToRes<PostResponse>(msg);
794 editPostFindRes(post_view, this.state.personRes?.posts);
795 this.setState(this.state);
800 case UserOperation.CreatePostLike: {
801 const { post_view } = wsJsonToRes<PostResponse>(msg);
802 createPostLikeFindRes(post_view, this.state.personRes?.posts);
803 this.setState(this.state);
808 case UserOperation.BanPerson: {
809 const data = wsJsonToRes<BanPersonResponse>(msg);
810 const 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 const 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);
827 case UserOperation.BlockPerson: {
828 const data = wsJsonToRes<BlockPersonResponse>(msg);
829 updatePersonBlock(data);
830 this.setPersonBlock();
835 case UserOperation.PurgePerson:
836 case UserOperation.PurgePost:
837 case UserOperation.PurgeComment:
838 case UserOperation.PurgeCommunity: {
839 const { success } = wsJsonToRes<PurgeItemResponse>(msg);
842 toast(i18n.t("purge_success"));
843 this.context.router.history.push(`/`);