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";
13 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";
32 capitalizeFirstLetter,
34 createPostLikeFindRes,
51 restoreScrollPosition,
62 import { BannerIconHeader } from "../common/banner-icon-header";
63 import { HtmlTags } from "../common/html-tags";
64 import { Icon, Spinner } from "../common/icon";
65 import { MomentTime } from "../common/moment-time";
66 import { SortSelect } from "../common/sort-select";
67 import { CommunityLink } from "../community/community-link";
68 import { PersonDetails } from "./person-details";
69 import { PersonListing } from "./person-listing";
71 interface ProfileState {
72 personRes?: GetPersonDetailsResponse;
74 personBlocked: boolean;
76 banExpireDays?: number;
77 showBanDialog: boolean;
79 siteRes: GetSiteResponse;
82 interface ProfileProps {
83 view: PersonDetailsView;
88 const getProfileQueryParams = () =>
89 getQueryParams<ProfileProps>({
90 view: getViewFromProps,
91 page: getPageFromString,
92 sort: getSortTypeFromQuery,
95 const getSortTypeFromQuery = (sort?: string): SortType =>
96 sort ? routeSortTypeToEnum(sort, SortType.New) : SortType.New;
98 const getViewFromProps = (view?: string): PersonDetailsView =>
100 ? PersonDetailsView[view] ?? PersonDetailsView.Overview
101 : PersonDetailsView.Overview;
103 function toggleBlockPerson(recipientId: number, block: boolean) {
104 const auth = myAuth();
107 const blockUserForm: BlockPerson = {
108 person_id: recipientId,
113 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
117 const handleUnblockPerson = (personId: number) =>
118 toggleBlockPerson(personId, false);
120 const handleBlockPerson = (personId: number) =>
121 toggleBlockPerson(personId, true);
123 const getCommunitiesListing = (
124 translationKey: NoOptionI18nKeys,
125 communityViews?: { community: CommunitySafe }[]
128 communityViews.length > 0 && (
129 <div className="card border-secondary mb-3">
130 <div className="card-body">
131 <h5>{i18n.t(translationKey)}</h5>
132 <ul className="list-unstyled mb-0">
133 {communityViews.map(({ community }) => (
134 <li key={community.id}>
135 <CommunityLink community={community} />
143 const Moderates = ({ moderates }: { moderates?: CommunityModeratorView[] }) =>
144 getCommunitiesListing("moderates", moderates);
146 const Follows = () =>
147 getCommunitiesListing("subscribed", UserService.Instance.myUserInfo?.follows);
149 export class Profile extends Component<
150 RouteComponentProps<{ username: string }>,
153 private isoData = setIsoData(this.context);
154 private subscription?: Subscription;
155 state: ProfileState = {
157 personBlocked: false,
158 siteRes: this.isoData.site_res,
159 showBanDialog: false,
163 constructor(props: RouteComponentProps<{ username: string }>, context: any) {
164 super(props, context);
166 this.handleSortChange = this.handleSortChange.bind(this);
167 this.handlePageChange = this.handlePageChange.bind(this);
169 this.parseMessage = this.parseMessage.bind(this);
170 this.subscription = wsSubscribe(this.parseMessage);
172 // Only fetch the data if coming from another route
173 if (this.isoData.path === this.context.router.route.match.url) {
176 personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
180 this.fetchUserData();
185 const { page, sort, view } = getProfileQueryParams();
187 const form: GetPersonDetails = {
188 username: this.props.match.params.username,
190 saved_only: view === PersonDetailsView.Saved,
196 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
199 get amCurrentUser() {
201 UserService.Instance.myUserInfo?.local_user_view.person.id ===
202 this.state.personRes?.person_view.person.id
207 const mui = UserService.Instance.myUserInfo;
208 const res = this.state.personRes;
212 personBlocked: mui.person_blocks.some(
213 ({ target: { id } }) => id === res.person_view.person.id
219 static fetchInitialData({
222 query: { page, sort, view: urlView },
224 }: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<any>[] {
225 const pathSplit = path.split("/");
227 const username = pathSplit[2];
228 const view = getViewFromProps(urlView);
230 const form: GetPersonDetails = {
232 sort: getSortTypeFromQuery(sort),
233 saved_only: view === PersonDetailsView.Saved,
234 page: getPageFromString(page),
239 return [client.getPersonDetails(form)];
242 componentDidMount() {
243 this.setPersonBlock();
247 componentWillUnmount() {
248 this.subscription?.unsubscribe();
249 saveScrollPosition(this.context);
252 get documentTitle(): string {
253 const res = this.state.personRes;
255 ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
260 const { personRes, loading, siteRes } = this.state;
261 const { page, sort, view } = getProfileQueryParams();
264 <div className="container-lg">
271 <div className="row">
272 <div className="col-12 col-md-8">
274 title={this.documentTitle}
275 path={this.context.router.route.match.url}
276 description={personRes.person_view.person.bio}
277 image={personRes.person_view.person.avatar}
287 personRes={personRes}
288 admins={siteRes.admins}
292 enableDownvotes={enableDownvotes(siteRes)}
293 enableNsfw={enableNsfw(siteRes)}
295 onPageChange={this.handlePageChange}
296 allLanguages={siteRes.all_languages}
297 siteLanguages={siteRes.discussion_languages}
301 <div className="col-12 col-md-4">
302 <Moderates moderates={personRes.moderates} />
303 {this.amCurrentUser && <Follows />}
314 <div className="btn-group btn-group-toggle flex-wrap mb-2">
315 {this.getRadio(PersonDetailsView.Overview)}
316 {this.getRadio(PersonDetailsView.Comments)}
317 {this.getRadio(PersonDetailsView.Posts)}
318 {this.getRadio(PersonDetailsView.Saved)}
323 getRadio(view: PersonDetailsView) {
324 const { view: urlView } = getProfileQueryParams();
325 const active = view === urlView;
329 className={classNames("btn btn-outline-secondary pointer", {
337 onChange={linkEvent(this, this.handleViewChange)}
339 {i18n.t(view.toLowerCase() as NoOptionI18nKeys)}
345 const { sort } = getProfileQueryParams();
346 const { username } = this.props.match.params;
348 const profileRss = `/feeds/u/${username}.xml?sort=${sort}`;
351 <div className="mb-2">
352 <span className="mr-3">{this.viewRadios}</span>
355 onChange={this.handleSortChange}
359 <a href={profileRss} rel={relTags} title="RSS">
360 <Icon icon="rss" classes="text-muted small mx-2" />
362 <link rel="alternate" type="application/atom+xml" href={profileRss} />
368 const pv = this.state.personRes?.person_view;
378 {!isBanned(pv.person) && (
380 banner={pv.person.banner}
381 icon={pv.person.avatar}
384 <div className="mb-3">
386 <div className="mb-0 d-flex flex-wrap">
388 {pv.person.display_name && (
389 <h5 className="mb-0">{pv.person.display_name}</h5>
391 <ul className="list-inline mb-2">
392 <li className="list-inline-item">
401 {isBanned(pv.person) && (
402 <li className="list-inline-item badge badge-danger">
406 {pv.person.deleted && (
407 <li className="list-inline-item badge badge-danger">
411 {pv.person.admin && (
412 <li className="list-inline-item badge badge-light">
416 {pv.person.bot_account && (
417 <li className="list-inline-item badge badge-light">
418 {i18n.t("bot_account").toLowerCase()}
424 <div className="flex-grow-1 unselectable pointer mx-2"></div>
425 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
428 className={`d-flex align-self-start btn btn-secondary mr-2 ${
429 !pv.person.matrix_user_id && "invisible"
432 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
434 {i18n.t("send_secure_message")}
438 "d-flex align-self-start btn btn-secondary mr-2"
440 to={`/create_private_message/${pv.person.id}`}
442 {i18n.t("send_message")}
447 "d-flex align-self-start btn btn-secondary mr-2"
449 onClick={linkEvent(pv.person.id, handleUnblockPerson)}
451 {i18n.t("unblock_user")}
456 "d-flex align-self-start btn btn-secondary mr-2"
458 onClick={linkEvent(pv.person.id, handleBlockPerson)}
460 {i18n.t("block_user")}
466 {canMod(pv.person.id, undefined, admins) &&
467 !isAdmin(pv.person.id, admins) &&
469 (!isBanned(pv.person) ? (
472 "d-flex align-self-start btn btn-secondary mr-2"
474 onClick={linkEvent(this, this.handleModBanShow)}
475 aria-label={i18n.t("ban")}
477 {capitalizeFirstLetter(i18n.t("ban"))}
482 "d-flex align-self-start btn btn-secondary mr-2"
484 onClick={linkEvent(this, this.handleModBanSubmit)}
485 aria-label={i18n.t("unban")}
487 {capitalizeFirstLetter(i18n.t("unban"))}
492 <div className="d-flex align-items-center mb-2">
495 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
500 <ul className="list-inline mb-2">
501 <li className="list-inline-item badge badge-light">
502 {i18n.t("number_of_posts", {
503 count: pv.counts.post_count,
504 formattedCount: numToSI(pv.counts.post_count),
507 <li className="list-inline-item badge badge-light">
508 {i18n.t("number_of_comments", {
509 count: pv.counts.comment_count,
510 formattedCount: numToSI(pv.counts.comment_count),
515 <div className="text-muted">
516 {i18n.t("joined")}{" "}
518 published={pv.person.published}
523 <div className="d-flex align-items-center text-muted mb-2">
525 <span className="ml-2">
526 {i18n.t("cake_day_title")}{" "}
528 .utc(pv.person.published)
530 .format("MMM DD, YYYY")}
541 const pv = this.state.personRes?.person_view;
542 const { showBanDialog } = this.state;
548 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
549 <div className="form-group row col-12">
550 <label className="col-form-label" htmlFor="profile-ban-reason">
555 id="profile-ban-reason"
556 className="form-control mr-2"
557 placeholder={i18n.t("reason")}
558 value={this.state.banReason}
559 onInput={linkEvent(this, this.handleModBanReasonChange)}
561 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
566 id={`mod-ban-expires`}
567 className="form-control mr-2"
568 placeholder={i18n.t("number_of_days")}
569 value={this.state.banExpireDays}
570 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
572 <div className="form-group">
573 <div className="form-check">
575 className="form-check-input"
576 id="mod-ban-remove-data"
578 checked={this.state.removeData}
579 onChange={linkEvent(this, this.handleModRemoveDataChange)}
582 className="form-check-label"
583 htmlFor="mod-ban-remove-data"
584 title={i18n.t("remove_content_more")}
586 {i18n.t("remove_content")}
591 {/* TODO hold off on expires until later */}
592 {/* <div class="form-group row"> */}
593 {/* <label class="col-form-label">Expires</label> */}
594 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
596 <div className="form-group row">
599 className="btn btn-secondary mr-2"
600 aria-label={i18n.t("cancel")}
601 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
607 className="btn btn-secondary"
608 aria-label={i18n.t("ban")}
610 {i18n.t("ban")} {pv.person.name}
620 updateUrl({ page, sort, view }: Partial<ProfileProps>) {
625 } = getProfileQueryParams();
627 const queryParams: QueryParams<ProfileProps> = {
628 page: (page ?? urlPage).toString(),
629 sort: sort ?? urlSort,
630 view: view ?? urlView,
633 const { username } = this.props.match.params;
635 this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
637 this.setState({ loading: true });
638 this.fetchUserData();
641 handlePageChange(page: number) {
642 this.updateUrl({ page });
645 handleSortChange(sort: SortType) {
646 this.updateUrl({ sort, page: 1 });
649 handleViewChange(i: Profile, event: any) {
651 view: PersonDetailsView[event.target.value],
656 handleModBanShow(i: Profile) {
657 i.setState({ showBanDialog: true });
660 handleModBanReasonChange(i: Profile, event: any) {
661 i.setState({ banReason: event.target.value });
664 handleModBanExpireDaysChange(i: Profile, event: any) {
665 i.setState({ banExpireDays: event.target.value });
668 handleModRemoveDataChange(i: Profile, event: any) {
669 i.setState({ removeData: event.target.checked });
672 handleModBanSubmitCancel(i: Profile, event?: any) {
673 event.preventDefault();
674 i.setState({ showBanDialog: false });
677 handleModBanSubmit(i: Profile, event?: any) {
678 if (event) event.preventDefault();
679 const { personRes, removeData, banReason, banExpireDays } = i.state;
681 const person = personRes?.person_view.person;
682 const auth = myAuth();
684 if (person && auth) {
685 const ban = !person.banned;
687 // If its an unban, restore all their data
689 i.setState({ removeData: false });
692 const form: BanPerson = {
693 person_id: person.id,
695 remove_data: removeData,
697 expires: futureDaysToUnixTime(banExpireDays),
700 WebSocketService.Instance.send(wsClient.banPerson(form));
702 i.setState({ showBanDialog: false });
706 parseMessage(msg: any) {
707 const op = wsUserOp(msg);
711 toast(i18n.t(msg.error), "danger");
713 if (msg.error === "couldnt_find_that_username_or_email") {
714 this.context.router.history.push("/");
716 } else if (msg.reconnect) {
717 this.fetchUserData();
720 case UserOperation.GetPersonDetails: {
721 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
722 // and set the parent state if it is not set or differs
723 // TODO this might need to get abstracted
724 const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
725 this.setState({ personRes: data, loading: false });
726 this.setPersonBlock();
727 restoreScrollPosition(this.context);
732 case UserOperation.AddAdmin: {
733 const { admins } = wsJsonToRes<AddAdminResponse>(msg);
734 this.setState(s => ((s.siteRes.admins = admins), s));
739 case UserOperation.CreateCommentLike: {
740 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
741 createCommentLikeRes(comment_view, this.state.personRes?.comments);
742 this.setState(this.state);
747 case UserOperation.EditComment:
748 case UserOperation.DeleteComment:
749 case UserOperation.RemoveComment: {
750 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
751 editCommentRes(comment_view, this.state.personRes?.comments);
752 this.setState(this.state);
757 case UserOperation.CreateComment: {
762 } = wsJsonToRes<CommentResponse>(msg);
763 const mui = UserService.Instance.myUserInfo;
765 if (id === mui?.local_user_view.person.id) {
766 toast(i18n.t("reply_sent"));
772 case UserOperation.SaveComment: {
773 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
774 saveCommentRes(comment_view, this.state.personRes?.comments);
775 this.setState(this.state);
780 case UserOperation.EditPost:
781 case UserOperation.DeletePost:
782 case UserOperation.RemovePost:
783 case UserOperation.LockPost:
784 case UserOperation.FeaturePost:
785 case UserOperation.SavePost: {
786 const { post_view } = wsJsonToRes<PostResponse>(msg);
787 editPostFindRes(post_view, this.state.personRes?.posts);
788 this.setState(this.state);
793 case UserOperation.CreatePostLike: {
794 const { post_view } = wsJsonToRes<PostResponse>(msg);
795 createPostLikeFindRes(post_view, this.state.personRes?.posts);
796 this.setState(this.state);
801 case UserOperation.BanPerson: {
802 const data = wsJsonToRes<BanPersonResponse>(msg);
803 const res = this.state.personRes;
805 .filter(c => c.creator.id === data.person_view.person.id)
806 .forEach(c => (c.creator.banned = data.banned));
808 .filter(c => c.creator.id === data.person_view.person.id)
809 .forEach(c => (c.creator.banned = data.banned));
810 const pv = res?.person_view;
812 if (pv?.person.id === data.person_view.person.id) {
813 pv.person.banned = data.banned;
815 this.setState(this.state);
820 case UserOperation.BlockPerson: {
821 const data = wsJsonToRes<BlockPersonResponse>(msg);
822 updatePersonBlock(data);
823 this.setPersonBlock();
828 case UserOperation.PurgePerson:
829 case UserOperation.PurgePost:
830 case UserOperation.PurgeComment:
831 case UserOperation.PurgeCommunity: {
832 const { success } = wsJsonToRes<PurgeItemResponse>(msg);
835 toast(i18n.t("purge_success"));
836 this.context.router.history.push(`/`);