1 import classNames from "classnames";
2 import { Component, linkEvent } from "inferno";
3 import { Link } from "inferno-router";
10 CommunityModeratorView,
24 } from "lemmy-js-client";
25 import { getExternalHost, getHttpBase } from "../../env";
26 import { i18n } from "../../i18next";
27 import { BanType, PostFormParams, PurgeType } from "../../interfaces";
28 import { UserService, WebSocketService } from "../../services";
54 import { Icon, PurgeWarning, Spinner } from "../common/icon";
55 import { MomentTime } from "../common/moment-time";
56 import { PictrsImage } from "../common/pictrs-image";
57 import { CommunityLink } from "../community/community-link";
58 import { PersonListing } from "../person/person-listing";
59 import { MetadataCard } from "./metadata-card";
60 import { PostForm } from "./post-form";
62 interface PostListingState {
64 showRemoveDialog: boolean;
65 showPurgeDialog: boolean;
67 purgeType?: PurgeType;
68 purgeLoading: boolean;
69 removeReason?: string;
70 showBanDialog: boolean;
72 banExpireDays?: number;
75 showConfirmTransferSite: boolean;
76 showConfirmTransferCommunity: boolean;
77 imageExpanded: boolean;
79 showAdvanced: boolean;
80 showMoreMobile: boolean;
82 showReportDialog: boolean;
83 reportReason?: string;
90 interface PostListingProps {
92 duplicates?: PostView[];
93 moderators?: CommunityModeratorView[];
94 admins?: PersonView[];
95 allLanguages: Language[];
96 siteLanguages: number[];
97 showCommunity?: boolean;
100 enableDownvotes?: boolean;
101 enableNsfw?: boolean;
105 export class PostListing extends Component<PostListingProps, PostListingState> {
106 state: PostListingState = {
108 showRemoveDialog: false,
109 showPurgeDialog: false,
110 purgeType: PurgeType.Person,
112 showBanDialog: false,
113 banType: BanType.Community,
115 showConfirmTransferSite: false,
116 showConfirmTransferCommunity: false,
117 imageExpanded: false,
120 showMoreMobile: false,
122 showReportDialog: false,
123 my_vote: this.props.post_view.my_vote,
124 score: this.props.post_view.counts.score,
125 upvotes: this.props.post_view.counts.upvotes,
126 downvotes: this.props.post_view.counts.downvotes,
129 constructor(props: any, context: any) {
130 super(props, context);
132 this.handlePostLike = this.handlePostLike.bind(this);
133 this.handlePostDisLike = this.handlePostDisLike.bind(this);
134 this.handleEditPost = this.handleEditPost.bind(this);
135 this.handleEditCancel = this.handleEditCancel.bind(this);
138 componentWillReceiveProps(nextProps: PostListingProps) {
140 my_vote: nextProps.post_view.my_vote,
141 upvotes: nextProps.post_view.counts.upvotes,
142 downvotes: nextProps.post_view.counts.downvotes,
143 score: nextProps.post_view.counts.score,
145 if (this.props.post_view.post.id !== nextProps.post_view.post.id) {
146 this.setState({ imageExpanded: false });
151 const post = this.props.post_view.post;
154 <div className="post-listing">
155 {!this.state.showEdit ? (
158 {this.state.imageExpanded && !this.props.hideImage && this.img}
159 {post.url && this.showBody && post.embed_title && (
160 <MetadataCard post={post} />
162 {this.showBody && this.body()}
165 <div className="col-12">
167 post_view={this.props.post_view}
168 onEdit={this.handleEditPost}
169 onCancel={this.handleEditCancel}
170 enableNsfw={this.props.enableNsfw}
171 enableDownvotes={this.props.enableDownvotes}
172 allLanguages={this.props.allLanguages}
173 siteLanguages={this.props.siteLanguages}
182 const body = this.props.post_view.post.body;
184 <div className="col-12 card my-2 p-2">
185 {this.state.viewSource ? (
188 <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
197 const src = this.imageSrc;
200 <div className="offset-sm-3 my-2 d-none d-sm-block">
201 <a href={src} className="d-inline-block">
202 <PictrsImage src={src} />
205 <div className="my-2 d-block d-sm-none">
207 className="d-inline-block"
208 onClick={linkEvent(this, this.handleImageExpandClick)}
210 <PictrsImage src={src} />
219 imgThumb(src: string) {
220 const post_view = this.props.post_view;
226 nsfw={post_view.post.nsfw || post_view.community.nsfw}
231 get imageSrc(): string | undefined {
232 const post = this.props.post_view.post;
233 const url = post.url;
234 const thumbnail = post.thumbnail_url;
236 if (url && isImage(url)) {
237 if (url.includes("pictrs")) {
239 } else if (thumbnail) {
244 } else if (thumbnail) {
252 const post = this.props.post_view.post;
253 const url = post.url;
254 const thumbnail = post.thumbnail_url;
256 if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
260 className="text-body d-inline-block position-relative mb-2"
261 data-tippy-content={i18n.t("expand_here")}
262 onClick={linkEvent(this, this.handleImageExpandClick)}
263 aria-label={i18n.t("expand_here")}
265 {this.imgThumb(this.imageSrc)}
266 <Icon icon="image" classes="mini-overlay" />
269 } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
272 className="text-body d-inline-block position-relative mb-2"
277 {this.imgThumb(this.imageSrc)}
278 <Icon icon="external-link" classes="mini-overlay" />
282 if (!this.props.hideImage && isVideo(url)) {
284 <div className="embed-responsive embed-responsive-16by9">
290 className="embed-responsive-item"
292 <source src={url} type="video/mp4" />
298 <a className="text-body" href={url} title={url} rel={relTags}>
299 <div className="thumbnail rounded bg-light d-flex justify-content-center">
300 <Icon icon="external-link" classes="d-flex align-items-center" />
308 className="text-body"
309 to={`/post/${post.id}`}
310 title={i18n.t("comments")}
312 <div className="thumbnail rounded bg-light d-flex justify-content-center">
313 <Icon icon="message-square" classes="d-flex align-items-center" />
321 const post_view = this.props.post_view;
322 const url = post_view.post.url;
323 const body = post_view.post.body;
325 <ul className="list-inline mb-1 text-muted small">
326 <li className="list-inline-item">
327 <PersonListing person={post_view.creator} />
329 {this.creatorIsMod_ && (
330 <span className="mx-1 badge">{i18n.t("mod")}</span>
332 {this.creatorIsAdmin_ && (
333 <span className="mx-1 badge">{i18n.t("admin")}</span>
335 {post_view.creator.bot_account && (
336 <span className="mx-1 badge">
337 {i18n.t("bot_account").toLowerCase()}
340 {this.props.showCommunity && (
342 <span className="mx-1"> {i18n.t("to")} </span>
343 <CommunityLink community={post_view.community} />
347 {post_view.post.language_id !== 0 && (
348 <span className="mx-1 badge">
350 this.props.allLanguages.find(
351 lang => lang.id === post_view.post.language_id
356 <li className="list-inline-item">•</li>
357 {url && !(hostname(url) === getExternalHost()) && (
359 <li className="list-inline-item">
361 className="text-muted font-italic"
369 <li className="list-inline-item">•</li>
372 <li className="list-inline-item">
375 published={post_view.post.published}
376 updated={post_view.post.updated}
382 <li className="list-inline-item">•</li>
383 <li className="list-inline-item">
385 className="text-muted btn btn-sm btn-link p-0"
386 data-tippy-content={mdNoImages.render(body)}
387 data-tippy-allowHtml={true}
388 onClick={linkEvent(this, this.handleShowBody)}
390 <Icon icon="book-open" classes="icon-inline mr-1" />
401 <div className={`vote-bar col-1 pr-0 small text-center`}>
403 className={`btn-animate btn btn-link p-0 ${
404 this.state.my_vote === 1 ? "text-info" : "text-muted"
406 onClick={this.handlePostLike}
407 data-tippy-content={i18n.t("upvote")}
408 aria-label={i18n.t("upvote")}
409 aria-pressed={this.state.my_vote === 1}
411 <Icon icon="arrow-up1" classes="upvote" />
415 className={`unselectable pointer font-weight-bold text-muted px-1`}
416 data-tippy-content={this.pointsTippy}
418 {numToSI(this.state.score)}
421 <div className="p-1"></div>
423 {this.props.enableDownvotes && (
425 className={`btn-animate btn btn-link p-0 ${
426 this.state.my_vote === -1 ? "text-danger" : "text-muted"
428 onClick={this.handlePostDisLike}
429 data-tippy-content={i18n.t("downvote")}
430 aria-label={i18n.t("downvote")}
431 aria-pressed={this.state.my_vote === -1}
433 <Icon icon="arrow-down1" classes="downvote" />
441 const post = this.props.post_view.post;
444 className={`d-inline-block ${
445 !post.featured_community && !post.featured_local
449 to={`/post/${post.id}`}
450 title={i18n.t("comments")}
453 className="d-inline-block"
454 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
461 const post = this.props.post_view.post;
462 const url = post.url;
465 <div className="post-title overflow-hidden">
468 this.props.showBody ? (
470 className={`d-inline-block ${
471 !post.featured_community && !post.featured_local
480 className="d-inline-block"
481 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
490 {(url && isImage(url)) ||
491 (post.thumbnail_url && (
493 className="btn btn-link text-monospace text-muted small d-inline-block"
494 data-tippy-content={i18n.t("expand_here")}
495 onClick={linkEvent(this, this.handleImageExpandClick)}
499 !this.state.imageExpanded ? "plus-square" : "minus-square"
501 classes="icon-inline"
506 <small className="ml-2 text-muted font-italic">
512 className="unselectable pointer ml-2 text-muted font-italic"
513 data-tippy-content={i18n.t("deleted")}
515 <Icon icon="trash" classes="icon-inline text-danger" />
520 className="unselectable pointer ml-2 text-muted font-italic"
521 data-tippy-content={i18n.t("locked")}
523 <Icon icon="lock" classes="icon-inline text-danger" />
526 {post.featured_community && (
528 className="unselectable pointer ml-2 text-muted font-italic"
529 data-tippy-content={i18n.t("featured")}
531 <Icon icon="pin" classes="icon-inline text-primary" />
534 {post.featured_local && (
536 className="unselectable pointer ml-2 text-muted font-italic"
537 data-tippy-content={i18n.t("featured")}
539 <Icon icon="pin" classes="icon-inline text-secondary" />
543 <small className="ml-2 text-muted font-italic">
553 const dupes = this.props.duplicates;
554 return dupes && dupes.length > 0 ? (
555 <ul className="list-inline mb-1 small text-muted">
557 <li className="list-inline-item mr-2">{i18n.t("cross_posted_to")}</li>
559 <li key={pv.post.id} className="list-inline-item mr-2">
560 <Link to={`/post/${pv.post.id}`}>
563 : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
574 commentsLine(mobile = false) {
575 const post = this.props.post_view.post;
578 <div className="d-flex justify-content-start flex-wrap text-muted font-weight-bold mb-1">
579 {this.commentsButton}
582 className="btn btn-link"
583 onClick={linkEvent(this, this.handleShare)}
586 <Icon icon="share" inline />
591 className="btn btn-link btn-animate text-muted py-0"
592 title={i18n.t("link")}
595 <Icon icon="fedilink" inline />
598 {mobile && !this.props.viewOnly && this.mobileVotes}
599 {UserService.Instance.myUserInfo &&
600 !this.props.viewOnly &&
601 this.postActions(mobile)}
606 postActions(mobile = false) {
607 // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
608 // Possible enhancement: Make each button a component.
609 const post_view = this.props.post_view;
613 {this.crossPostButton}
614 {mobile && this.showMoreButton}
615 {(!mobile || this.state.showAdvanced) && (
623 {this.myPost && (this.showBody || this.state.showAdvanced) && (
631 {this.state.showAdvanced && (
633 {this.showBody && post_view.post.body && this.viewSourceButton}
634 {/* Any mod can do these, not limited to hierarchy*/}
635 {(amMod(this.props.moderators) || amAdmin()) && (
641 {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}</>}
644 {!mobile && this.showMoreButton}
649 get commentsButton() {
650 const post_view = this.props.post_view;
652 <button className="btn btn-link text-muted py-0 pl-0">
654 className="text-muted"
655 title={i18n.t("number_of_comments", {
656 count: Number(post_view.counts.comments),
657 formattedCount: Number(post_view.counts.comments),
659 to={`/post/${post_view.post.id}?scrollToComments=true`}
661 <Icon icon="message-square" classes="mr-1" inline />
662 <span className="mr-2">
663 {i18n.t("number_of_comments", {
664 count: Number(post_view.counts.comments),
665 formattedCount: numToSI(post_view.counts.comments),
668 {this.unreadCount && (
669 <span className="small text-warning">
670 ({this.unreadCount} {i18n.t("new")})
678 get unreadCount(): number | undefined {
679 const pv = this.props.post_view;
680 return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
682 : pv.unread_comments;
687 const tippy = showScores()
688 ? { "data-tippy-content": this.pointsTippy }
694 className={`btn-animate btn py-0 px-1 ${
695 this.state.my_vote === 1 ? "text-info" : "text-muted"
698 onClick={this.handlePostLike}
699 aria-label={i18n.t("upvote")}
700 aria-pressed={this.state.my_vote === 1}
702 <Icon icon="arrow-up1" classes="icon-inline small" />
704 <span className="ml-2">{numToSI(this.state.upvotes)}</span>
707 {this.props.enableDownvotes && (
709 className={`ml-2 btn-animate btn py-0 px-1 ${
710 this.state.my_vote === -1 ? "text-danger" : "text-muted"
712 onClick={this.handlePostDisLike}
714 aria-label={i18n.t("downvote")}
715 aria-pressed={this.state.my_vote === -1}
717 <Icon icon="arrow-down1" classes="icon-inline small" />
720 className={classNames("ml-2", {
721 invisible: this.state.downvotes === 0,
724 {numToSI(this.state.downvotes)}
735 const saved = this.props.post_view.saved;
736 const label = saved ? i18n.t("unsave") : i18n.t("save");
739 className="btn btn-link btn-animate text-muted py-0"
740 onClick={linkEvent(this, this.handleSavePostClick)}
741 data-tippy-content={label}
746 classes={classNames({ "text-warning": saved })}
753 get crossPostButton() {
756 className="btn btn-link btn-animate text-muted py-0"
758 /* Empty string properties are required to satisfy type*/
759 pathname: "/create_post",
760 state: { ...this.crossPostParams },
765 title={i18n.t("cross_post")}
767 <Icon icon="copy" inline />
775 className="btn btn-link btn-animate text-muted py-0"
776 onClick={linkEvent(this, this.handleShowReportDialog)}
777 data-tippy-content={i18n.t("show_report_dialog")}
778 aria-label={i18n.t("show_report_dialog")}
780 <Icon icon="flag" inline />
788 className="btn btn-link btn-animate text-muted py-0"
789 onClick={linkEvent(this, this.handleBlockUserClick)}
790 data-tippy-content={i18n.t("block_user")}
791 aria-label={i18n.t("block_user")}
793 <Icon icon="slash" inline />
801 className="btn btn-link btn-animate text-muted py-0"
802 onClick={linkEvent(this, this.handleEditClick)}
803 data-tippy-content={i18n.t("edit")}
804 aria-label={i18n.t("edit")}
806 <Icon icon="edit" inline />
812 const deleted = this.props.post_view.post.deleted;
813 const label = !deleted ? i18n.t("delete") : i18n.t("restore");
816 className="btn btn-link btn-animate text-muted py-0"
817 onClick={linkEvent(this, this.handleDeleteClick)}
818 data-tippy-content={label}
823 classes={classNames({ "text-danger": deleted })}
830 get showMoreButton() {
833 className="btn btn-link btn-animate text-muted py-0"
834 onClick={linkEvent(this, this.handleShowAdvanced)}
835 data-tippy-content={i18n.t("more")}
836 aria-label={i18n.t("more")}
838 <Icon icon="more-vertical" inline />
843 get viewSourceButton() {
846 className="btn btn-link btn-animate text-muted py-0"
847 onClick={linkEvent(this, this.handleViewSource)}
848 data-tippy-content={i18n.t("view_source")}
849 aria-label={i18n.t("view_source")}
853 classes={classNames({ "text-success": this.state.viewSource })}
861 const locked = this.props.post_view.post.locked;
862 const label = locked ? i18n.t("unlock") : i18n.t("lock");
865 className="btn btn-link btn-animate text-muted py-0"
866 onClick={linkEvent(this, this.handleModLock)}
867 data-tippy-content={label}
872 classes={classNames({ "text-danger": locked })}
879 get featureButton() {
880 const featuredCommunity = this.props.post_view.post.featured_community;
881 const labelCommunity = featuredCommunity
882 ? i18n.t("unfeature_from_community")
883 : i18n.t("feature_in_community");
885 const featuredLocal = this.props.post_view.post.featured_local;
886 const labelLocal = featuredLocal
887 ? i18n.t("unfeature_from_local")
888 : i18n.t("feature_in_local");
892 className="btn btn-link btn-animate text-muted py-0 pl-0"
893 onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
894 data-tippy-content={labelCommunity}
895 aria-label={labelCommunity}
899 classes={classNames({ "text-success": featuredCommunity })}
906 className="btn btn-link btn-animate text-muted py-0"
907 onClick={linkEvent(this, this.handleModFeaturePostLocal)}
908 data-tippy-content={labelLocal}
909 aria-label={labelLocal}
913 classes={classNames({ "text-success": featuredLocal })}
923 get modRemoveButton() {
924 const removed = this.props.post_view.post.removed;
927 className="btn btn-link btn-animate text-muted py-0"
930 !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
933 {/* TODO: Find an icon for this. */}
934 {!removed ? i18n.t("remove") : i18n.t("restore")}
940 * Mod/Admin actions to be taken against the author.
944 const post_view = this.props.post_view;
946 this.state.showAdvanced && (
950 {!this.creatorIsMod_ &&
951 (!post_view.creator_banned_from_community ? (
953 className="btn btn-link btn-animate text-muted py-0"
956 this.handleModBanFromCommunityShow
958 aria-label={i18n.t("ban_from_community")}
960 {i18n.t("ban_from_community")}
964 className="btn btn-link btn-animate text-muted py-0"
967 this.handleModBanFromCommunitySubmit
969 aria-label={i18n.t("unban")}
974 {!post_view.creator_banned_from_community && (
976 className="btn btn-link btn-animate text-muted py-0"
977 onClick={linkEvent(this, this.handleAddModToCommunity)}
980 ? i18n.t("remove_as_mod")
981 : i18n.t("appoint_as_mod")
985 ? i18n.t("remove_as_mod")
986 : i18n.t("appoint_as_mod")}
991 {/* Community creators and admins can transfer community to another mod */}
992 {(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
994 this.creatorIsMod_ &&
995 (!this.state.showConfirmTransferCommunity ? (
997 className="btn btn-link btn-animate text-muted py-0"
1000 this.handleShowConfirmTransferCommunity
1002 aria-label={i18n.t("transfer_community")}
1004 {i18n.t("transfer_community")}
1009 className="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
1010 aria-label={i18n.t("are_you_sure")}
1012 {i18n.t("are_you_sure")}
1015 className="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
1016 aria-label={i18n.t("yes")}
1017 onClick={linkEvent(this, this.handleTransferCommunity)}
1022 className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1025 this.handleCancelShowConfirmTransferCommunity
1027 aria-label={i18n.t("no")}
1033 {/* Admins can ban from all, and appoint other admins */}
1034 {this.canAdmin_ && (
1036 {!this.creatorIsAdmin_ && (
1038 {!isBanned(post_view.creator) ? (
1040 className="btn btn-link btn-animate text-muted py-0"
1041 onClick={linkEvent(this, this.handleModBanShow)}
1042 aria-label={i18n.t("ban_from_site")}
1044 {i18n.t("ban_from_site")}
1048 className="btn btn-link btn-animate text-muted py-0"
1049 onClick={linkEvent(this, this.handleModBanSubmit)}
1050 aria-label={i18n.t("unban_from_site")}
1052 {i18n.t("unban_from_site")}
1056 className="btn btn-link btn-animate text-muted py-0"
1057 onClick={linkEvent(this, this.handlePurgePersonShow)}
1058 aria-label={i18n.t("purge_user")}
1060 {i18n.t("purge_user")}
1063 className="btn btn-link btn-animate text-muted py-0"
1064 onClick={linkEvent(this, this.handlePurgePostShow)}
1065 aria-label={i18n.t("purge_post")}
1067 {i18n.t("purge_post")}
1071 {!isBanned(post_view.creator) && post_view.creator.local && (
1073 className="btn btn-link btn-animate text-muted py-0"
1074 onClick={linkEvent(this, this.handleAddAdmin)}
1076 this.creatorIsAdmin_
1077 ? i18n.t("remove_as_admin")
1078 : i18n.t("appoint_as_admin")
1081 {this.creatorIsAdmin_
1082 ? i18n.t("remove_as_admin")
1083 : i18n.t("appoint_as_admin")}
1093 removeAndBanDialogs() {
1094 const post = this.props.post_view;
1095 const purgeTypeText =
1096 this.state.purgeType == PurgeType.Post
1097 ? i18n.t("purge_post")
1098 : `${i18n.t("purge")} ${post.creator.name}`;
1101 {this.state.showRemoveDialog && (
1103 className="form-inline"
1104 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1106 <label className="sr-only" htmlFor="post-listing-remove-reason">
1111 id="post-listing-remove-reason"
1112 className="form-control mr-2"
1113 placeholder={i18n.t("reason")}
1114 value={this.state.removeReason}
1115 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1119 className="btn btn-secondary"
1120 aria-label={i18n.t("remove_post")}
1122 {i18n.t("remove_post")}
1126 {this.state.showBanDialog && (
1127 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1128 <div className="form-group row col-12">
1130 className="col-form-label"
1131 htmlFor="post-listing-ban-reason"
1137 id="post-listing-ban-reason"
1138 className="form-control mr-2"
1139 placeholder={i18n.t("reason")}
1140 value={this.state.banReason}
1141 onInput={linkEvent(this, this.handleModBanReasonChange)}
1143 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1148 id={`mod-ban-expires`}
1149 className="form-control mr-2"
1150 placeholder={i18n.t("number_of_days")}
1151 value={this.state.banExpireDays}
1152 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1154 <div className="form-group">
1155 <div className="form-check">
1157 className="form-check-input"
1158 id="mod-ban-remove-data"
1160 checked={this.state.removeData}
1161 onChange={linkEvent(this, this.handleModRemoveDataChange)}
1164 className="form-check-label"
1165 htmlFor="mod-ban-remove-data"
1166 title={i18n.t("remove_content_more")}
1168 {i18n.t("remove_content")}
1173 {/* TODO hold off on expires until later */}
1174 {/* <div class="form-group row"> */}
1175 {/* <label class="col-form-label">Expires</label> */}
1176 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1178 <div className="form-group row">
1181 className="btn btn-secondary"
1182 aria-label={i18n.t("ban")}
1184 {i18n.t("ban")} {post.creator.name}
1189 {this.state.showReportDialog && (
1191 className="form-inline"
1192 onSubmit={linkEvent(this, this.handleReportSubmit)}
1194 <label className="sr-only" htmlFor="post-report-reason">
1199 id="post-report-reason"
1200 className="form-control mr-2"
1201 placeholder={i18n.t("reason")}
1203 value={this.state.reportReason}
1204 onInput={linkEvent(this, this.handleReportReasonChange)}
1208 className="btn btn-secondary"
1209 aria-label={i18n.t("create_report")}
1211 {i18n.t("create_report")}
1215 {this.state.showPurgeDialog && (
1217 className="form-inline"
1218 onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1221 <label className="sr-only" htmlFor="purge-reason">
1227 className="form-control mr-2"
1228 placeholder={i18n.t("reason")}
1229 value={this.state.purgeReason}
1230 onInput={linkEvent(this, this.handlePurgeReasonChange)}
1232 {this.state.purgeLoading ? (
1237 className="btn btn-secondary"
1238 aria-label={purgeTypeText}
1250 const post = this.props.post_view.post;
1251 return post.thumbnail_url || (post.url && isImage(post.url)) ? (
1252 <div className="row">
1253 <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1254 {this.postTitleLine()}
1256 <div className="col-4">
1257 {/* Post body prev or thumbnail */}
1258 {!this.state.imageExpanded && this.thumbnail()}
1262 this.postTitleLine()
1266 showMobilePreview() {
1267 const body = this.props.post_view.post.body;
1268 return !this.showBody && body ? (
1269 <div className="md-div mb-1 preview-lines">{body}</div>
1278 {/* The mobile view*/}
1279 <div className="d-block d-sm-none">
1280 <div className="row">
1281 <div className="col-12">
1282 {this.createdLine()}
1284 {/* If it has a thumbnail, do a right aligned thumbnail */}
1285 {this.mobileThumbnail()}
1287 {/* Show a preview of the post body */}
1288 {this.showMobilePreview()}
1290 {this.commentsLine(true)}
1291 {this.userActionsLine()}
1292 {this.duplicatesLine()}
1293 {this.removeAndBanDialogs()}
1298 {/* The larger view*/}
1299 <div className="d-none d-sm-block">
1300 <div className="row">
1301 {!this.props.viewOnly && this.voteBar()}
1302 <div className="col-sm-2 pr-0">
1303 <div className="">{this.thumbnail()}</div>
1305 <div className="col-12 col-sm-9">
1306 <div className="row">
1307 <div className="col-12">
1308 {this.postTitleLine()}
1309 {this.createdLine()}
1310 {this.commentsLine()}
1311 {this.duplicatesLine()}
1312 {this.userActionsLine()}
1313 {this.removeAndBanDialogs()}
1323 private get myPost(): boolean {
1325 this.props.post_view.creator.id ==
1326 UserService.Instance.myUserInfo?.local_user_view.person.id
1330 handlePostLike(event: any) {
1331 event.preventDefault();
1332 if (!UserService.Instance.myUserInfo) {
1333 this.context.router.history.push(`/login`);
1336 const myVote = this.state.my_vote;
1337 const newVote = myVote == 1 ? 0 : 1;
1341 score: this.state.score - 1,
1342 upvotes: this.state.upvotes - 1,
1344 } else if (myVote == -1) {
1346 score: this.state.score + 2,
1347 upvotes: this.state.upvotes + 1,
1348 downvotes: this.state.downvotes - 1,
1352 score: this.state.score + 1,
1353 upvotes: this.state.upvotes + 1,
1357 this.setState({ my_vote: newVote });
1359 const auth = myAuth();
1361 const form: CreatePostLike = {
1362 post_id: this.props.post_view.post.id,
1367 WebSocketService.Instance.send(wsClient.likePost(form));
1368 this.setState(this.state);
1373 handlePostDisLike(event: any) {
1374 event.preventDefault();
1375 if (!UserService.Instance.myUserInfo) {
1376 this.context.router.history.push(`/login`);
1379 const myVote = this.state.my_vote;
1380 const newVote = myVote == -1 ? 0 : -1;
1384 score: this.state.score - 2,
1385 upvotes: this.state.upvotes - 1,
1386 downvotes: this.state.downvotes + 1,
1388 } else if (myVote == -1) {
1390 score: this.state.score + 1,
1391 downvotes: this.state.downvotes - 1,
1395 score: this.state.score - 1,
1396 downvotes: this.state.downvotes + 1,
1400 this.setState({ my_vote: newVote });
1402 const auth = myAuth();
1404 const form: CreatePostLike = {
1405 post_id: this.props.post_view.post.id,
1410 WebSocketService.Instance.send(wsClient.likePost(form));
1411 this.setState(this.state);
1416 handleEditClick(i: PostListing) {
1417 i.setState({ showEdit: true });
1420 handleEditCancel() {
1421 this.setState({ showEdit: false });
1424 // The actual editing is done in the receive for post
1426 this.setState({ showEdit: false });
1429 handleShare(i: PostListing) {
1430 const { name, body, id } = i.props.post_view.post;
1433 text: body?.slice(0, 50),
1434 url: `${getHttpBase()}/post/${id}`,
1438 handleShowReportDialog(i: PostListing) {
1439 i.setState({ showReportDialog: !i.state.showReportDialog });
1442 handleReportReasonChange(i: PostListing, event: any) {
1443 i.setState({ reportReason: event.target.value });
1446 handleReportSubmit(i: PostListing, event: any) {
1447 event.preventDefault();
1448 const auth = myAuth();
1449 const reason = i.state.reportReason;
1450 if (auth && reason) {
1451 const form: CreatePostReport = {
1452 post_id: i.props.post_view.post.id,
1456 WebSocketService.Instance.send(wsClient.createPostReport(form));
1458 i.setState({ showReportDialog: false });
1462 handleBlockUserClick(i: PostListing) {
1463 const auth = myAuth();
1465 const blockUserForm: BlockPerson = {
1466 person_id: i.props.post_view.creator.id,
1470 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1474 handleDeleteClick(i: PostListing) {
1475 const auth = myAuth();
1477 const deleteForm: DeletePost = {
1478 post_id: i.props.post_view.post.id,
1479 deleted: !i.props.post_view.post.deleted,
1482 WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1486 handleSavePostClick(i: PostListing) {
1487 const auth = myAuth();
1490 i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1491 const form: SavePost = {
1492 post_id: i.props.post_view.post.id,
1496 WebSocketService.Instance.send(wsClient.savePost(form));
1500 get crossPostParams(): PostFormParams {
1501 const queryParams: PostFormParams = {};
1502 const { name, url } = this.props.post_view.post;
1504 queryParams.name = name;
1507 queryParams.url = url;
1510 const crossPostBody = this.crossPostBody();
1511 if (crossPostBody) {
1512 queryParams.body = crossPostBody;
1518 crossPostBody(): string | undefined {
1519 const post = this.props.post_view.post;
1520 const body = post.body;
1523 ? `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${body.replace(
1530 get showBody(): boolean {
1531 return this.props.showBody || this.state.showBody;
1534 handleModRemoveShow(i: PostListing) {
1536 showRemoveDialog: !i.state.showRemoveDialog,
1537 showBanDialog: false,
1541 handleModRemoveReasonChange(i: PostListing, event: any) {
1542 i.setState({ removeReason: event.target.value });
1545 handleModRemoveDataChange(i: PostListing, event: any) {
1546 i.setState({ removeData: event.target.checked });
1549 handleModRemoveSubmit(i: PostListing, event: any) {
1550 event.preventDefault();
1552 const auth = myAuth();
1554 const form: RemovePost = {
1555 post_id: i.props.post_view.post.id,
1556 removed: !i.props.post_view.post.removed,
1557 reason: i.state.removeReason,
1560 WebSocketService.Instance.send(wsClient.removePost(form));
1561 i.setState({ showRemoveDialog: false });
1565 handleModLock(i: PostListing) {
1566 const auth = myAuth();
1568 const form: LockPost = {
1569 post_id: i.props.post_view.post.id,
1570 locked: !i.props.post_view.post.locked,
1573 WebSocketService.Instance.send(wsClient.lockPost(form));
1577 handleModFeaturePostLocal(i: PostListing) {
1578 const auth = myAuth();
1580 const form: FeaturePost = {
1581 post_id: i.props.post_view.post.id,
1582 feature_type: "Local",
1583 featured: !i.props.post_view.post.featured_local,
1586 WebSocketService.Instance.send(wsClient.featurePost(form));
1590 handleModFeaturePostCommunity(i: PostListing) {
1591 const auth = myAuth();
1593 const form: FeaturePost = {
1594 post_id: i.props.post_view.post.id,
1595 feature_type: "Community",
1596 featured: !i.props.post_view.post.featured_community,
1599 WebSocketService.Instance.send(wsClient.featurePost(form));
1603 handleModBanFromCommunityShow(i: PostListing) {
1605 showBanDialog: true,
1606 banType: BanType.Community,
1607 showRemoveDialog: false,
1611 handleModBanShow(i: PostListing) {
1613 showBanDialog: true,
1614 banType: BanType.Site,
1615 showRemoveDialog: false,
1619 handlePurgePersonShow(i: PostListing) {
1621 showPurgeDialog: true,
1622 purgeType: PurgeType.Person,
1623 showRemoveDialog: false,
1627 handlePurgePostShow(i: PostListing) {
1629 showPurgeDialog: true,
1630 purgeType: PurgeType.Post,
1631 showRemoveDialog: false,
1635 handlePurgeReasonChange(i: PostListing, event: any) {
1636 i.setState({ purgeReason: event.target.value });
1639 handlePurgeSubmit(i: PostListing, event: any) {
1640 event.preventDefault();
1642 const auth = myAuth();
1644 if (i.state.purgeType == PurgeType.Person) {
1645 const form: PurgePerson = {
1646 person_id: i.props.post_view.creator.id,
1647 reason: i.state.purgeReason,
1650 WebSocketService.Instance.send(wsClient.purgePerson(form));
1651 } else if (i.state.purgeType == PurgeType.Post) {
1652 const form: PurgePost = {
1653 post_id: i.props.post_view.post.id,
1654 reason: i.state.purgeReason,
1657 WebSocketService.Instance.send(wsClient.purgePost(form));
1660 i.setState({ purgeLoading: true });
1664 handleModBanReasonChange(i: PostListing, event: any) {
1665 i.setState({ banReason: event.target.value });
1668 handleModBanExpireDaysChange(i: PostListing, event: any) {
1669 i.setState({ banExpireDays: event.target.value });
1672 handleModBanFromCommunitySubmit(i: PostListing) {
1673 i.setState({ banType: BanType.Community });
1674 i.handleModBanBothSubmit(i);
1677 handleModBanSubmit(i: PostListing) {
1678 i.setState({ banType: BanType.Site });
1679 i.handleModBanBothSubmit(i);
1682 handleModBanBothSubmit(i: PostListing, event?: any) {
1683 if (event) event.preventDefault();
1684 const auth = myAuth();
1686 const ban = !i.props.post_view.creator_banned_from_community;
1687 const person_id = i.props.post_view.creator.id;
1688 const remove_data = i.state.removeData;
1689 const reason = i.state.banReason;
1690 const expires = futureDaysToUnixTime(i.state.banExpireDays);
1692 if (i.state.banType == BanType.Community) {
1693 // If its an unban, restore all their data
1695 i.setState({ removeData: false });
1698 const form: BanFromCommunity = {
1700 community_id: i.props.post_view.community.id,
1707 WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1709 // If its an unban, restore all their data
1710 const ban = !i.props.post_view.creator.banned;
1712 i.setState({ removeData: false });
1714 const form: BanPerson = {
1722 WebSocketService.Instance.send(wsClient.banPerson(form));
1725 i.setState({ showBanDialog: false });
1729 handleAddModToCommunity(i: PostListing) {
1730 const auth = myAuth();
1732 const form: AddModToCommunity = {
1733 person_id: i.props.post_view.creator.id,
1734 community_id: i.props.post_view.community.id,
1735 added: !i.creatorIsMod_,
1738 WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1739 i.setState(i.state);
1743 handleAddAdmin(i: PostListing) {
1744 const auth = myAuth();
1746 const form: AddAdmin = {
1747 person_id: i.props.post_view.creator.id,
1748 added: !i.creatorIsAdmin_,
1751 WebSocketService.Instance.send(wsClient.addAdmin(form));
1752 i.setState(i.state);
1756 handleShowConfirmTransferCommunity(i: PostListing) {
1757 i.setState({ showConfirmTransferCommunity: true });
1760 handleCancelShowConfirmTransferCommunity(i: PostListing) {
1761 i.setState({ showConfirmTransferCommunity: false });
1764 handleTransferCommunity(i: PostListing) {
1765 const auth = myAuth();
1767 const form: TransferCommunity = {
1768 community_id: i.props.post_view.community.id,
1769 person_id: i.props.post_view.creator.id,
1772 WebSocketService.Instance.send(wsClient.transferCommunity(form));
1773 i.setState({ showConfirmTransferCommunity: false });
1777 handleShowConfirmTransferSite(i: PostListing) {
1778 i.setState({ showConfirmTransferSite: true });
1781 handleCancelShowConfirmTransferSite(i: PostListing) {
1782 i.setState({ showConfirmTransferSite: false });
1785 handleImageExpandClick(i: PostListing, event: any) {
1786 event.preventDefault();
1787 i.setState({ imageExpanded: !i.state.imageExpanded });
1791 handleViewSource(i: PostListing) {
1792 i.setState({ viewSource: !i.state.viewSource });
1795 handleShowAdvanced(i: PostListing) {
1796 i.setState({ showAdvanced: !i.state.showAdvanced });
1800 handleShowMoreMobile(i: PostListing) {
1802 showMoreMobile: !i.state.showMoreMobile,
1803 showAdvanced: !i.state.showAdvanced,
1808 handleShowBody(i: PostListing) {
1809 i.setState({ showBody: !i.state.showBody });
1813 get pointsTippy(): string {
1814 const points = i18n.t("number_of_points", {
1815 count: Number(this.state.score),
1816 formattedCount: Number(this.state.score),
1819 const upvotes = i18n.t("number_of_upvotes", {
1820 count: Number(this.state.upvotes),
1821 formattedCount: Number(this.state.upvotes),
1824 const downvotes = i18n.t("number_of_downvotes", {
1825 count: Number(this.state.downvotes),
1826 formattedCount: Number(this.state.downvotes),
1829 return `${points} • ${upvotes} • ${downvotes}`;
1832 get canModOnSelf_(): boolean {
1834 this.props.post_view.creator.id,
1835 this.props.moderators,
1842 get canMod_(): boolean {
1844 this.props.post_view.creator.id,
1845 this.props.moderators,
1850 get canAdmin_(): boolean {
1851 return canAdmin(this.props.post_view.creator.id, this.props.admins);
1854 get creatorIsMod_(): boolean {
1855 return isMod(this.props.post_view.creator.id, this.props.moderators);
1858 get creatorIsAdmin_(): boolean {
1859 return isAdmin(this.props.post_view.creator.id, this.props.admins);