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";
34 capitalizeFirstLetter,
36 createPostLikeFindRes,
52 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 ProfileData {
72 personResponse: GetPersonDetailsResponse;
75 interface ProfileState {
76 personRes?: GetPersonDetailsResponse;
78 personBlocked: boolean;
80 banExpireDays?: number;
81 showBanDialog: boolean;
83 siteRes: GetSiteResponse;
86 interface ProfileProps {
87 view: PersonDetailsView;
92 function getProfileQueryParams() {
93 return getQueryParams<ProfileProps>({
94 view: getViewFromProps,
95 page: getPageFromString,
96 sort: getSortTypeFromQuery,
100 function getSortTypeFromQuery(sort?: string): SortType {
101 return sort ? (sort as SortType) : "New";
104 function getViewFromProps(view?: string): PersonDetailsView {
106 ? PersonDetailsView[view] ?? PersonDetailsView.Overview
107 : PersonDetailsView.Overview;
110 function toggleBlockPerson(recipientId: number, block: boolean) {
111 const auth = myAuth();
114 const blockUserForm: BlockPerson = {
115 person_id: recipientId,
120 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
124 const handleUnblockPerson = (personId: number) =>
125 toggleBlockPerson(personId, false);
127 const handleBlockPerson = (personId: number) =>
128 toggleBlockPerson(personId, true);
130 const getCommunitiesListing = (
131 translationKey: NoOptionI18nKeys,
132 communityViews?: { community: Community }[]
135 communityViews.length > 0 && (
136 <div className="card border-secondary mb-3">
137 <div className="card-body">
138 <h5>{i18n.t(translationKey)}</h5>
139 <ul className="list-unstyled mb-0">
140 {communityViews.map(({ community }) => (
141 <li key={community.id}>
142 <CommunityLink community={community} />
150 const Moderates = ({ moderates }: { moderates?: CommunityModeratorView[] }) =>
151 getCommunitiesListing("moderates", moderates);
153 const Follows = () =>
154 getCommunitiesListing("subscribed", UserService.Instance.myUserInfo?.follows);
156 export class Profile extends Component<
157 RouteComponentProps<{ username: string }>,
160 private isoData = setIsoData<ProfileData>(this.context);
161 private subscription?: Subscription;
162 state: ProfileState = {
164 personBlocked: false,
165 siteRes: this.isoData.site_res,
166 showBanDialog: false,
170 constructor(props: RouteComponentProps<{ username: string }>, context: any) {
171 super(props, context);
173 this.handleSortChange = this.handleSortChange.bind(this);
174 this.handlePageChange = this.handlePageChange.bind(this);
176 this.parseMessage = this.parseMessage.bind(this);
177 this.subscription = wsSubscribe(this.parseMessage);
179 // Only fetch the data if coming from another route
180 if (this.isoData.path === this.context.router.route.match.url) {
183 personRes: this.isoData.routeData.personResponse,
187 this.fetchUserData();
192 const { page, sort, view } = getProfileQueryParams();
194 const form: GetPersonDetails = {
195 username: this.props.match.params.username,
197 saved_only: view === PersonDetailsView.Saved,
203 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
206 get amCurrentUser() {
208 UserService.Instance.myUserInfo?.local_user_view.person.id ===
209 this.state.personRes?.person_view.person.id
214 const mui = UserService.Instance.myUserInfo;
215 const res = this.state.personRes;
219 personBlocked: mui.person_blocks.some(
220 ({ target: { id } }) => id === res.person_view.person.id
226 static fetchInitialData({
229 query: { page, sort, view: urlView },
231 }: InitialFetchRequest<
232 QueryParams<ProfileProps>
233 >): WithPromiseKeys<ProfileData> {
234 const pathSplit = path.split("/");
236 const username = pathSplit[2];
237 const view = getViewFromProps(urlView);
239 const form: GetPersonDetails = {
241 sort: getSortTypeFromQuery(sort),
242 saved_only: view === PersonDetailsView.Saved,
243 page: getPageFromString(page),
249 personResponse: client.getPersonDetails(form),
253 componentDidMount() {
254 this.setPersonBlock();
258 componentWillUnmount() {
259 this.subscription?.unsubscribe();
260 saveScrollPosition(this.context);
263 get documentTitle(): string {
264 const res = this.state.personRes;
266 ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
271 const { personRes, loading, siteRes } = this.state;
272 const { page, sort, view } = getProfileQueryParams();
275 <div className="container-lg">
282 <div className="row">
283 <div className="col-12 col-md-8">
285 title={this.documentTitle}
286 path={this.context.router.route.match.url}
287 description={personRes.person_view.person.bio}
288 image={personRes.person_view.person.avatar}
298 personRes={personRes}
299 admins={siteRes.admins}
303 enableDownvotes={enableDownvotes(siteRes)}
304 enableNsfw={enableNsfw(siteRes)}
306 onPageChange={this.handlePageChange}
307 allLanguages={siteRes.all_languages}
308 siteLanguages={siteRes.discussion_languages}
312 <div className="col-12 col-md-4">
313 <Moderates moderates={personRes.moderates} />
314 {this.amCurrentUser && <Follows />}
325 <div className="btn-group btn-group-toggle flex-wrap mb-2">
326 {this.getRadio(PersonDetailsView.Overview)}
327 {this.getRadio(PersonDetailsView.Comments)}
328 {this.getRadio(PersonDetailsView.Posts)}
329 {this.getRadio(PersonDetailsView.Saved)}
334 getRadio(view: PersonDetailsView) {
335 const { view: urlView } = getProfileQueryParams();
336 const active = view === urlView;
340 className={classNames("btn btn-outline-secondary pointer", {
348 onChange={linkEvent(this, this.handleViewChange)}
350 {i18n.t(view.toLowerCase() as NoOptionI18nKeys)}
356 const { sort } = getProfileQueryParams();
357 const { username } = this.props.match.params;
359 const profileRss = `/feeds/u/${username}.xml?sort=${sort}`;
362 <div className="mb-2">
363 <span className="mr-3">{this.viewRadios}</span>
366 onChange={this.handleSortChange}
370 <a href={profileRss} rel={relTags} title="RSS">
371 <Icon icon="rss" classes="text-muted small mx-2" />
373 <link rel="alternate" type="application/atom+xml" href={profileRss} />
379 const pv = this.state.personRes?.person_view;
389 {!isBanned(pv.person) && (
391 banner={pv.person.banner}
392 icon={pv.person.avatar}
395 <div className="mb-3">
397 <div className="mb-0 d-flex flex-wrap">
399 {pv.person.display_name && (
400 <h5 className="mb-0">{pv.person.display_name}</h5>
402 <ul className="list-inline mb-2">
403 <li className="list-inline-item">
412 {isBanned(pv.person) && (
413 <li className="list-inline-item badge badge-danger">
417 {pv.person.deleted && (
418 <li className="list-inline-item badge badge-danger">
422 {pv.person.admin && (
423 <li className="list-inline-item badge badge-light">
427 {pv.person.bot_account && (
428 <li className="list-inline-item badge badge-light">
429 {i18n.t("bot_account").toLowerCase()}
435 <div className="flex-grow-1 unselectable pointer mx-2"></div>
436 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
439 className={`d-flex align-self-start btn btn-secondary mr-2 ${
440 !pv.person.matrix_user_id && "invisible"
443 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
445 {i18n.t("send_secure_message")}
449 "d-flex align-self-start btn btn-secondary mr-2"
451 to={`/create_private_message/${pv.person.id}`}
453 {i18n.t("send_message")}
458 "d-flex align-self-start btn btn-secondary mr-2"
460 onClick={linkEvent(pv.person.id, handleUnblockPerson)}
462 {i18n.t("unblock_user")}
467 "d-flex align-self-start btn btn-secondary mr-2"
469 onClick={linkEvent(pv.person.id, handleBlockPerson)}
471 {i18n.t("block_user")}
477 {canMod(pv.person.id, undefined, admins) &&
478 !isAdmin(pv.person.id, admins) &&
480 (!isBanned(pv.person) ? (
483 "d-flex align-self-start btn btn-secondary mr-2"
485 onClick={linkEvent(this, this.handleModBanShow)}
486 aria-label={i18n.t("ban")}
488 {capitalizeFirstLetter(i18n.t("ban"))}
493 "d-flex align-self-start btn btn-secondary mr-2"
495 onClick={linkEvent(this, this.handleModBanSubmit)}
496 aria-label={i18n.t("unban")}
498 {capitalizeFirstLetter(i18n.t("unban"))}
503 <div className="d-flex align-items-center mb-2">
506 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
511 <ul className="list-inline mb-2">
512 <li className="list-inline-item badge badge-light">
513 {i18n.t("number_of_posts", {
514 count: Number(pv.counts.post_count),
515 formattedCount: numToSI(pv.counts.post_count),
518 <li className="list-inline-item badge badge-light">
519 {i18n.t("number_of_comments", {
520 count: Number(pv.counts.comment_count),
521 formattedCount: numToSI(pv.counts.comment_count),
526 <div className="text-muted">
527 {i18n.t("joined")}{" "}
529 published={pv.person.published}
534 <div className="d-flex align-items-center text-muted mb-2">
536 <span className="ml-2">
537 {i18n.t("cake_day_title")}{" "}
539 .utc(pv.person.published)
541 .format("MMM DD, YYYY")}
544 {!UserService.Instance.myUserInfo && (
545 <div className="alert alert-info" role="alert">
546 {i18n.t("profile_not_logged_in_alert")}
557 const pv = this.state.personRes?.person_view;
558 const { showBanDialog } = this.state;
564 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
565 <div className="form-group row col-12">
566 <label className="col-form-label" htmlFor="profile-ban-reason">
571 id="profile-ban-reason"
572 className="form-control mr-2"
573 placeholder={i18n.t("reason")}
574 value={this.state.banReason}
575 onInput={linkEvent(this, this.handleModBanReasonChange)}
577 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
582 id={`mod-ban-expires`}
583 className="form-control mr-2"
584 placeholder={i18n.t("number_of_days")}
585 value={this.state.banExpireDays}
586 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
588 <div className="form-group">
589 <div className="form-check">
591 className="form-check-input"
592 id="mod-ban-remove-data"
594 checked={this.state.removeData}
595 onChange={linkEvent(this, this.handleModRemoveDataChange)}
598 className="form-check-label"
599 htmlFor="mod-ban-remove-data"
600 title={i18n.t("remove_content_more")}
602 {i18n.t("remove_content")}
607 {/* TODO hold off on expires until later */}
608 {/* <div class="form-group row"> */}
609 {/* <label class="col-form-label">Expires</label> */}
610 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
612 <div className="form-group row">
615 className="btn btn-secondary mr-2"
616 aria-label={i18n.t("cancel")}
617 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
623 className="btn btn-secondary"
624 aria-label={i18n.t("ban")}
626 {i18n.t("ban")} {pv.person.name}
636 updateUrl({ page, sort, view }: Partial<ProfileProps>) {
641 } = getProfileQueryParams();
643 const queryParams: QueryParams<ProfileProps> = {
644 page: (page ?? urlPage).toString(),
645 sort: sort ?? urlSort,
646 view: view ?? urlView,
649 const { username } = this.props.match.params;
651 this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
653 this.setState({ loading: true });
654 this.fetchUserData();
657 handlePageChange(page: number) {
658 this.updateUrl({ page });
661 handleSortChange(sort: SortType) {
662 this.updateUrl({ sort, page: 1 });
665 handleViewChange(i: Profile, event: any) {
667 view: PersonDetailsView[event.target.value],
672 handleModBanShow(i: Profile) {
673 i.setState({ showBanDialog: true });
676 handleModBanReasonChange(i: Profile, event: any) {
677 i.setState({ banReason: event.target.value });
680 handleModBanExpireDaysChange(i: Profile, event: any) {
681 i.setState({ banExpireDays: event.target.value });
684 handleModRemoveDataChange(i: Profile, event: any) {
685 i.setState({ removeData: event.target.checked });
688 handleModBanSubmitCancel(i: Profile, event?: any) {
689 event.preventDefault();
690 i.setState({ showBanDialog: false });
693 handleModBanSubmit(i: Profile, event?: any) {
694 if (event) event.preventDefault();
695 const { personRes, removeData, banReason, banExpireDays } = i.state;
697 const person = personRes?.person_view.person;
698 const auth = myAuth();
700 if (person && auth) {
701 const ban = !person.banned;
703 // If its an unban, restore all their data
705 i.setState({ removeData: false });
708 const form: BanPerson = {
709 person_id: person.id,
711 remove_data: removeData,
713 expires: futureDaysToUnixTime(banExpireDays),
716 WebSocketService.Instance.send(wsClient.banPerson(form));
718 i.setState({ showBanDialog: false });
722 parseMessage(msg: any) {
723 const op = wsUserOp(msg);
727 toast(i18n.t(msg.error), "danger");
729 if (msg.error === "couldnt_find_that_username_or_email") {
730 this.context.router.history.push("/");
732 } else if (msg.reconnect) {
733 this.fetchUserData();
736 case UserOperation.GetPersonDetails: {
737 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
738 // and set the parent state if it is not set or differs
739 // TODO this might need to get abstracted
740 const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
741 this.setState({ personRes: data, loading: false });
742 this.setPersonBlock();
743 restoreScrollPosition(this.context);
748 case UserOperation.AddAdmin: {
749 const { admins } = wsJsonToRes<AddAdminResponse>(msg);
750 this.setState(s => ((s.siteRes.admins = admins), s));
755 case UserOperation.CreateCommentLike: {
756 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
757 createCommentLikeRes(comment_view, this.state.personRes?.comments);
758 this.setState(this.state);
763 case UserOperation.EditComment:
764 case UserOperation.DeleteComment:
765 case UserOperation.RemoveComment: {
766 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
767 editCommentRes(comment_view, this.state.personRes?.comments);
768 this.setState(this.state);
773 case UserOperation.CreateComment: {
778 } = wsJsonToRes<CommentResponse>(msg);
779 const mui = UserService.Instance.myUserInfo;
781 if (id === mui?.local_user_view.person.id) {
782 toast(i18n.t("reply_sent"));
788 case UserOperation.SaveComment: {
789 const { comment_view } = wsJsonToRes<CommentResponse>(msg);
790 saveCommentRes(comment_view, this.state.personRes?.comments);
791 this.setState(this.state);
796 case UserOperation.EditPost:
797 case UserOperation.DeletePost:
798 case UserOperation.RemovePost:
799 case UserOperation.LockPost:
800 case UserOperation.FeaturePost:
801 case UserOperation.SavePost: {
802 const { post_view } = wsJsonToRes<PostResponse>(msg);
803 editPostFindRes(post_view, this.state.personRes?.posts);
804 this.setState(this.state);
809 case UserOperation.CreatePostLike: {
810 const { post_view } = wsJsonToRes<PostResponse>(msg);
811 createPostLikeFindRes(post_view, this.state.personRes?.posts);
812 this.setState(this.state);
817 case UserOperation.BanPerson: {
818 const data = wsJsonToRes<BanPersonResponse>(msg);
819 const res = this.state.personRes;
821 .filter(c => c.creator.id === data.person_view.person.id)
822 .forEach(c => (c.creator.banned = data.banned));
824 .filter(c => c.creator.id === data.person_view.person.id)
825 .forEach(c => (c.creator.banned = data.banned));
826 const pv = res?.person_view;
828 if (pv?.person.id === data.person_view.person.id) {
829 pv.person.banned = data.banned;
831 this.setState(this.state);
836 case UserOperation.BlockPerson: {
837 const data = wsJsonToRes<BlockPersonResponse>(msg);
838 updatePersonBlock(data);
839 this.setPersonBlock();
844 case UserOperation.PurgePerson:
845 case UserOperation.PurgePost:
846 case UserOperation.PurgeComment:
847 case UserOperation.PurgeCommunity: {
848 const { success } = wsJsonToRes<PurgeItemResponse>(msg);
851 toast(i18n.t("purge_success"));
852 this.context.router.history.push(`/`);