1 import { myAuthRequired, newVote, showScores } from "@utils/app";
2 import { canShare, share } from "@utils/browser";
3 import { getExternalHost, getHttpBase } from "@utils/env";
9 } from "@utils/helpers";
10 import { isImage, isVideo } from "@utils/media";
20 } from "@utils/roles";
21 import classNames from "classnames";
22 import { Component, linkEvent } from "inferno";
23 import { Link } from "inferno-router";
30 CommunityModeratorView,
45 } from "lemmy-js-client";
46 import { relTags } from "../../config";
47 import { BanType, PostFormParams, PurgeType, VoteType } from "../../interfaces";
48 import { mdNoImages, mdToHtml, mdToHtmlInline } from "../../markdown";
49 import { I18NextService, UserService } from "../../services";
50 import { setupTippy } from "../../tippy";
51 import { Icon, PurgeWarning, Spinner } from "../common/icon";
52 import { MomentTime } from "../common/moment-time";
53 import { PictrsImage } from "../common/pictrs-image";
54 import { CommunityLink } from "../community/community-link";
55 import { PersonListing } from "../person/person-listing";
56 import { MetadataCard } from "./metadata-card";
57 import { PostForm } from "./post-form";
59 interface PostListingState {
61 showRemoveDialog: boolean;
62 showPurgeDialog: boolean;
64 purgeType?: PurgeType;
65 purgeLoading: boolean;
66 removeReason?: string;
67 showBanDialog: boolean;
69 banExpireDays?: number;
72 showConfirmTransferSite: boolean;
73 showConfirmTransferCommunity: boolean;
74 imageExpanded: boolean;
76 showAdvanced: boolean;
77 showMoreMobile: boolean;
79 showReportDialog: boolean;
80 reportReason?: string;
81 upvoteLoading: boolean;
82 downvoteLoading: boolean;
83 reportLoading: boolean;
84 blockLoading: boolean;
86 deleteLoading: boolean;
87 removeLoading: boolean;
89 featureCommunityLoading: boolean;
90 featureLocalLoading: boolean;
92 addModLoading: boolean;
93 addAdminLoading: boolean;
94 transferLoading: boolean;
97 interface PostListingProps {
99 crossPosts?: PostView[];
100 moderators?: CommunityModeratorView[];
101 admins?: PersonView[];
102 allLanguages: Language[];
103 siteLanguages: number[];
104 showCommunity?: boolean;
107 enableDownvotes?: boolean;
108 enableNsfw?: boolean;
110 onPostEdit(form: EditPost): void;
111 onPostVote(form: CreatePostLike): void;
112 onPostReport(form: CreatePostReport): void;
113 onBlockPerson(form: BlockPerson): void;
114 onLockPost(form: LockPost): void;
115 onDeletePost(form: DeletePost): void;
116 onRemovePost(form: RemovePost): void;
117 onSavePost(form: SavePost): void;
118 onFeaturePost(form: FeaturePost): void;
119 onPurgePerson(form: PurgePerson): void;
120 onPurgePost(form: PurgePost): void;
121 onBanPersonFromCommunity(form: BanFromCommunity): void;
122 onBanPerson(form: BanPerson): void;
123 onAddModToCommunity(form: AddModToCommunity): void;
124 onAddAdmin(form: AddAdmin): void;
125 onTransferCommunity(form: TransferCommunity): void;
128 export class PostListing extends Component<PostListingProps, PostListingState> {
129 state: PostListingState = {
131 showRemoveDialog: false,
132 showPurgeDialog: false,
133 purgeType: PurgeType.Person,
134 showBanDialog: false,
135 banType: BanType.Community,
137 showConfirmTransferSite: false,
138 showConfirmTransferCommunity: false,
139 imageExpanded: false,
142 showMoreMobile: false,
144 showReportDialog: false,
145 upvoteLoading: false,
146 downvoteLoading: false,
148 reportLoading: false,
151 deleteLoading: false,
152 removeLoading: false,
154 featureCommunityLoading: false,
155 featureLocalLoading: false,
157 addModLoading: false,
158 addAdminLoading: false,
159 transferLoading: false,
162 constructor(props: any, context: any) {
163 super(props, context);
165 this.handleEditPost = this.handleEditPost.bind(this);
166 this.handleEditCancel = this.handleEditCancel.bind(this);
169 componentWillReceiveProps(nextProps: PostListingProps) {
170 if (this.props !== nextProps) {
172 upvoteLoading: false,
173 downvoteLoading: false,
175 reportLoading: false,
178 deleteLoading: false,
179 removeLoading: false,
181 featureCommunityLoading: false,
182 featureLocalLoading: false,
184 addModLoading: false,
185 addAdminLoading: false,
186 transferLoading: false,
187 imageExpanded: false,
192 get postView(): PostView {
193 return this.props.post_view;
197 const post = this.postView.post;
200 <div className="post-listing mt-2">
201 {!this.state.showEdit ? (
204 {this.state.imageExpanded && !this.props.hideImage && this.img}
205 {post.url && this.state.showBody && post.embed_title && (
206 <MetadataCard post={post} />
208 {this.showBody && this.body()}
212 post_view={this.postView}
213 crossPosts={this.props.crossPosts}
214 onEdit={this.handleEditPost}
215 onCancel={this.handleEditCancel}
216 enableNsfw={this.props.enableNsfw}
217 enableDownvotes={this.props.enableDownvotes}
218 allLanguages={this.props.allLanguages}
219 siteLanguages={this.props.siteLanguages}
227 const body = this.postView.post.body;
229 <article id="postContent" className="col-12 card my-2 p-2">
230 {this.state.viewSource ? (
233 <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
245 <div className="offset-sm-3 my-2 d-none d-sm-block">
246 <a href={this.imageSrc} className="d-inline-block">
247 <PictrsImage src={this.imageSrc} />
250 <div className="my-2 d-block d-sm-none">
253 className="p-0 border-0 bg-transparent d-inline-block"
254 onClick={linkEvent(this, this.handleImageExpandClick)}
256 <PictrsImage src={this.imageSrc} />
263 const { post } = this.postView;
264 const { url } = post;
266 if (url && isVideo(url)) {
268 <div className="embed-responsive mt-3">
269 <video muted controls className="embed-responsive-item col-12">
270 <source src={url} type="video/mp4" />
279 imgThumb(src: string) {
280 const post_view = this.postView;
286 nsfw={post_view.post.nsfw || post_view.community.nsfw}
291 get imageSrc(): string | undefined {
292 const post = this.postView.post;
293 const url = post.url;
294 const thumbnail = post.thumbnail_url;
296 if (url && isImage(url)) {
297 if (url.includes("pictrs")) {
299 } else if (thumbnail) {
304 } else if (thumbnail) {
312 const post = this.postView.post;
313 const url = post.url;
314 const thumbnail = post.thumbnail_url;
316 if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
320 className="text-body d-inline-block position-relative mb-2"
321 data-tippy-content={I18NextService.i18n.t("expand_here")}
322 onClick={linkEvent(this, this.handleImageExpandClick)}
323 aria-label={I18NextService.i18n.t("expand_here")}
325 {this.imgThumb(this.imageSrc)}
326 <Icon icon="image" classes="mini-overlay" />
329 } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
332 className="text-body d-inline-block position-relative mb-2"
337 {this.imgThumb(this.imageSrc)}
338 <Icon icon="external-link" classes="mini-overlay" />
342 if (!this.props.hideImage && isVideo(url)) {
345 className="text-body"
349 data-tippy-content={I18NextService.i18n.t("expand_here")}
350 onClick={linkEvent(this, this.handleImageExpandClick)}
351 aria-label={I18NextService.i18n.t("expand_here")}
353 <div className="thumbnail rounded bg-light d-flex justify-content-center">
354 <Icon icon="play" classes="d-flex align-items-center" />
360 <a className="text-body" href={url} title={url} rel={relTags}>
361 <div className="thumbnail rounded bg-light d-flex justify-content-center">
362 <Icon icon="external-link" classes="d-flex align-items-center" />
370 className="text-body"
371 to={`/post/${post.id}`}
372 title={I18NextService.i18n.t("comments")}
374 <div className="thumbnail rounded bg-light d-flex justify-content-center">
375 <Icon icon="message-square" classes="d-flex align-items-center" />
383 const post_view = this.postView;
385 <span className="small">
386 <PersonListing person={post_view.creator} />
387 {this.creatorIsMod_ && (
388 <span className="mx-1 badge text-bg-light">
389 {I18NextService.i18n.t("mod")}
392 {this.creatorIsAdmin_ && (
393 <span className="mx-1 badge text-bg-light">
394 {I18NextService.i18n.t("admin")}
397 {post_view.creator.bot_account && (
398 <span className="mx-1 badge text-bg-light">
399 {I18NextService.i18n.t("bot_account").toLowerCase()}
402 {this.props.showCommunity && (
405 {I18NextService.i18n.t("to")}{" "}
406 <CommunityLink community={post_view.community} />
409 {post_view.post.language_id !== 0 && (
410 <span className="mx-1 badge text-bg-light">
412 this.props.allLanguages.find(
413 lang => lang.id === post_view.post.language_id
420 published={post_view.post.published}
421 updated={post_view.post.updated}
429 <div className={`vote-bar col-1 pe-0 small text-center`}>
431 className={`btn-animate btn btn-link p-0 ${
432 this.postView.my_vote == 1 ? "text-info" : "text-muted"
434 onClick={linkEvent(this, this.handleUpvote)}
435 data-tippy-content={I18NextService.i18n.t("upvote")}
436 aria-label={I18NextService.i18n.t("upvote")}
437 aria-pressed={this.postView.my_vote === 1}
439 {this.state.upvoteLoading ? (
442 <Icon icon="arrow-up1" classes="upvote" />
447 className={`unselectable pointer text-muted px-1 post-score`}
448 data-tippy-content={this.pointsTippy}
450 {numToSI(this.postView.counts.score)}
453 <div className="p-1"></div>
455 {this.props.enableDownvotes && (
457 className={`btn-animate btn btn-link p-0 ${
458 this.postView.my_vote == -1 ? "text-danger" : "text-muted"
460 onClick={linkEvent(this, this.handleDownvote)}
461 data-tippy-content={I18NextService.i18n.t("downvote")}
462 aria-label={I18NextService.i18n.t("downvote")}
463 aria-pressed={this.postView.my_vote === -1}
465 {this.state.downvoteLoading ? (
468 <Icon icon="arrow-down1" classes="downvote" />
477 const post = this.postView.post;
480 className={`d-inline ${
481 !post.featured_community && !post.featured_local
485 to={`/post/${post.id}`}
486 title={I18NextService.i18n.t("comments")}
490 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
497 const post = this.postView.post;
498 const url = post.url;
502 <div className="post-title overflow-hidden">
503 <h5 className="d-inline">
504 {url && this.props.showBody ? (
507 !post.featured_community && !post.featured_local
514 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
520 {(url && isImage(url)) ||
521 (post.thumbnail_url && (
523 className="btn btn-sm text-monospace text-muted d-inline-block"
524 data-tippy-content={I18NextService.i18n.t("expand_here")}
525 onClick={linkEvent(this, this.handleImageExpandClick)}
529 !this.state.imageExpanded ? "plus-square" : "minus-square"
531 classes="icon-inline"
536 <small className="ms-2 badge text-bg-secondary">
537 {I18NextService.i18n.t("removed")}
542 className="unselectable pointer ms-2 text-muted fst-italic"
543 data-tippy-content={I18NextService.i18n.t("deleted")}
545 <Icon icon="trash" classes="icon-inline text-danger" />
550 className="unselectable pointer ms-2 text-muted fst-italic"
551 data-tippy-content={I18NextService.i18n.t("locked")}
553 <Icon icon="lock" classes="icon-inline text-danger" />
556 {post.featured_community && (
558 className="unselectable pointer ms-2 text-muted fst-italic"
559 data-tippy-content={I18NextService.i18n.t(
560 "featured_in_community"
562 aria-label={I18NextService.i18n.t("featured_in_community")}
564 <Icon icon="pin" classes="icon-inline text-primary" />
567 {post.featured_local && (
569 className="unselectable pointer ms-2 text-muted fst-italic"
570 data-tippy-content={I18NextService.i18n.t("featured_in_local")}
571 aria-label={I18NextService.i18n.t("featured_in_local")}
573 <Icon icon="pin" classes="icon-inline text-secondary" />
577 <small className="ms-2 badge text-bg-danger">
578 {I18NextService.i18n.t("nsfw")}
582 {url && this.urlLine()}
588 const post = this.postView.post;
589 const url = post.url;
592 <p className="d-flex text-muted align-items-center gap-1 small m-0">
593 {url && !(hostname(url) === getExternalHost()) && (
595 className="text-muted fst-italic"
608 const dupes = this.props.crossPosts;
609 return dupes && dupes.length > 0 ? (
610 <ul className="list-inline mb-1 small text-muted">
612 <li className="list-inline-item me-2">
613 {I18NextService.i18n.t("cross_posted_to")}
616 <li key={pv.post.id} className="list-inline-item me-2">
617 <Link to={`/post/${pv.post.id}`}>
620 : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
631 commentsLine(mobile = false) {
632 const post = this.postView.post;
635 <div className="d-flex align-items-center justify-content-start flex-wrap text-muted">
636 {this.commentsButton}
639 className="btn btn-sm btn-animate text-muted py-0"
640 onClick={linkEvent(this, this.handleShare)}
643 <Icon icon="share" inline />
648 className="btn btn-sm btn-animate text-muted py-0"
649 title={I18NextService.i18n.t("link")}
652 <Icon icon="fedilink" inline />
655 {mobile && !this.props.viewOnly && this.mobileVotes}
656 {UserService.Instance.myUserInfo &&
657 !this.props.viewOnly &&
663 showPreviewButton() {
664 const post_view = this.postView;
665 const body = post_view.post.body;
669 className="btn btn-sm btn-animate text-muted py-0"
670 data-tippy-content={body && mdNoImages.render(body)}
671 data-tippy-allowHtml={true}
672 onClick={linkEvent(this, this.handleShowBody)}
676 classes={classNames("icon-inline me-1", {
677 "text-success": this.state.showBody,
685 // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
686 // Possible enhancement: Make each button a component.
687 const post_view = this.postView;
688 const post = post_view.post;
693 {this.crossPostButton}
696 * If there is a URL, or if the post has a body and we were told not to
697 * show the body, show the MetadataCard/body toggle.
699 {(post.url || (post.body && !this.props.showBody)) &&
700 this.showPreviewButton()}
702 {this.showBody && post_view.post.body && this.viewSourceButton}
704 <div className="dropdown">
706 className="btn btn-sm btn-animate text-muted py-0 dropdown-toggle"
707 onClick={linkEvent(this, this.handleShowAdvanced)}
708 data-tippy-content={I18NextService.i18n.t("more")}
709 data-bs-toggle="dropdown"
710 aria-expanded="false"
711 aria-controls={`advancedButtonsDropdown${post.id}`}
712 aria-label={I18NextService.i18n.t("more")}
714 <Icon icon="more-vertical" inline />
718 className="dropdown-menu"
719 id={`advancedButtonsDropdown${post.id}`}
723 <li>{this.reportButton}</li>
724 <li>{this.blockButton}</li>
728 <li>{this.editButton}</li>
729 <li>{this.deleteButton}</li>
733 {/* Any mod can do these, not limited to hierarchy*/}
734 {(amMod(this.props.moderators) || amAdmin()) && (
737 <hr className="dropdown-divider" />
739 <li>{this.lockButton}</li>
740 {this.featureButtons}
744 {(this.canMod_ || this.canAdmin_) && (
745 <li>{this.modRemoveButton}</li>
753 get commentsButton() {
754 const post_view = this.postView;
755 const title = I18NextService.i18n.t("number_of_comments", {
756 count: Number(post_view.counts.comments),
757 formattedCount: Number(post_view.counts.comments),
762 className="btn btn-link btn-sm text-muted ps-0"
764 to={`/post/${post_view.post.id}?scrollToComments=true`}
765 data-tippy-content={title}
767 <Icon icon="message-square" classes="me-1" inline />
768 {post_view.counts.comments}
769 {this.unreadCount && (
770 <span className="text-muted fst-italic">
771 ({this.unreadCount} {I18NextService.i18n.t("new")})
778 get unreadCount(): number | undefined {
779 const pv = this.postView;
780 return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
782 : pv.unread_comments;
787 const tippy = showScores()
788 ? { "data-tippy-content": this.pointsTippy }
794 className={`btn-animate btn py-0 px-1 ${
795 this.postView.my_vote === 1 ? "text-info" : "text-muted"
798 onClick={linkEvent(this, this.handleUpvote)}
799 aria-label={I18NextService.i18n.t("upvote")}
800 aria-pressed={this.postView.my_vote === 1}
802 {this.state.upvoteLoading ? (
806 <Icon icon="arrow-up1" classes="icon-inline small" />
808 <span className="ms-2">
809 {numToSI(this.postView.counts.upvotes)}
815 {this.props.enableDownvotes && (
817 className={`ms-2 btn-animate btn py-0 px-1 ${
818 this.postView.my_vote === -1 ? "text-danger" : "text-muted"
820 onClick={linkEvent(this, this.handleDownvote)}
822 aria-label={I18NextService.i18n.t("downvote")}
823 aria-pressed={this.postView.my_vote === -1}
825 {this.state.downvoteLoading ? (
829 <Icon icon="arrow-down1" classes="icon-inline small" />
832 className={classNames("ms-2", {
833 invisible: this.postView.counts.downvotes === 0,
836 {numToSI(this.postView.counts.downvotes)}
849 const saved = this.postView.saved;
851 ? I18NextService.i18n.t("unsave")
852 : I18NextService.i18n.t("save");
855 className="btn btn-sm btn-animate text-muted py-0"
856 onClick={linkEvent(this, this.handleSavePostClick)}
857 data-tippy-content={label}
860 {this.state.saveLoading ? (
865 classes={classNames({ "text-warning": saved })}
873 get crossPostButton() {
876 className="btn btn-sm btn-animate text-muted py-0"
878 /* Empty string properties are required to satisfy type*/
879 pathname: "/create_post",
880 state: { ...this.crossPostParams },
885 title={I18NextService.i18n.t("cross_post")}
886 data-tippy-content={I18NextService.i18n.t("cross_post")}
887 aria-label={I18NextService.i18n.t("cross_post")}
889 <Icon icon="copy" inline />
897 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
898 onClick={linkEvent(this, this.handleShowReportDialog)}
899 aria-label={I18NextService.i18n.t("show_report_dialog")}
901 <Icon classes="me-1" icon="flag" inline />
902 {I18NextService.i18n.t("create_report")}
910 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
911 onClick={linkEvent(this, this.handleBlockPersonClick)}
912 aria-label={I18NextService.i18n.t("block_user")}
914 {this.state.blockLoading ? (
917 <Icon classes="me-1" icon="slash" inline />
919 {I18NextService.i18n.t("block_user")}
927 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
928 onClick={linkEvent(this, this.handleEditClick)}
929 aria-label={I18NextService.i18n.t("edit")}
931 <Icon classes="me-1" icon="edit" inline />
932 {I18NextService.i18n.t("edit")}
938 const deleted = this.postView.post.deleted;
939 const label = !deleted
940 ? I18NextService.i18n.t("delete")
941 : I18NextService.i18n.t("restore");
944 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
945 onClick={linkEvent(this, this.handleDeleteClick)}
948 {this.state.deleteLoading ? (
954 classes={classNames("me-1", { "text-danger": deleted })}
964 get viewSourceButton() {
967 className="btn btn-sm btn-animate text-muted py-0"
968 onClick={linkEvent(this, this.handleViewSource)}
969 data-tippy-content={I18NextService.i18n.t("view_source")}
970 aria-label={I18NextService.i18n.t("view_source")}
974 classes={classNames({ "text-success": this.state.viewSource })}
982 const locked = this.postView.post.locked;
984 ? I18NextService.i18n.t("unlock")
985 : I18NextService.i18n.t("lock");
988 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
989 onClick={linkEvent(this, this.handleModLock)}
992 {this.state.lockLoading ? (
998 classes={classNames("me-1", { "text-danger": locked })}
1001 {capitalizeFirstLetter(label)}
1008 get featureButtons() {
1009 const featuredCommunity = this.postView.post.featured_community;
1010 const labelCommunity = featuredCommunity
1011 ? I18NextService.i18n.t("unfeature_from_community")
1012 : I18NextService.i18n.t("feature_in_community");
1014 const featuredLocal = this.postView.post.featured_local;
1015 const labelLocal = featuredLocal
1016 ? I18NextService.i18n.t("unfeature_from_local")
1017 : I18NextService.i18n.t("feature_in_local");
1022 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
1023 onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
1024 data-tippy-content={labelCommunity}
1025 aria-label={labelCommunity}
1027 {this.state.featureCommunityLoading ? (
1033 classes={classNames("me-1", {
1034 "text-success": featuredCommunity,
1038 {I18NextService.i18n.t("community")}
1046 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
1047 onClick={linkEvent(this, this.handleModFeaturePostLocal)}
1048 data-tippy-content={labelLocal}
1049 aria-label={labelLocal}
1051 {this.state.featureLocalLoading ? (
1057 classes={classNames("me-1", {
1058 "text-success": featuredLocal,
1062 {I18NextService.i18n.t("local")}
1072 get modRemoveButton() {
1073 const removed = this.postView.post.removed;
1076 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
1079 !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
1082 {/* TODO: Find an icon for this. */}
1083 {this.state.removeLoading ? (
1086 I18NextService.i18n.t("remove")
1088 I18NextService.i18n.t("restore")
1095 * Mod/Admin actions to be taken against the author.
1099 const post_view = this.postView;
1101 this.state.showAdvanced && (
1102 <div className="mt-3">
1105 {!this.creatorIsMod_ &&
1106 (!post_view.creator_banned_from_community ? (
1108 className="btn btn-link btn-animate text-muted py-0"
1111 this.handleModBanFromCommunityShow
1113 aria-label={I18NextService.i18n.t("ban_from_community")}
1115 {I18NextService.i18n.t("ban_from_community")}
1119 className="btn btn-link btn-animate text-muted py-0"
1122 this.handleModBanFromCommunitySubmit
1124 aria-label={I18NextService.i18n.t("unban")}
1126 {this.state.banLoading ? (
1129 I18NextService.i18n.t("unban")
1133 {!post_view.creator_banned_from_community && (
1135 className="btn btn-link btn-animate text-muted py-0"
1136 onClick={linkEvent(this, this.handleAddModToCommunity)}
1139 ? I18NextService.i18n.t("remove_as_mod")
1140 : I18NextService.i18n.t("appoint_as_mod")
1143 {this.state.addModLoading ? (
1145 ) : this.creatorIsMod_ ? (
1146 I18NextService.i18n.t("remove_as_mod")
1148 I18NextService.i18n.t("appoint_as_mod")
1154 {/* Community creators and admins can transfer community to another mod */}
1155 {(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
1157 this.creatorIsMod_ &&
1158 (!this.state.showConfirmTransferCommunity ? (
1160 className="btn btn-link btn-animate text-muted py-0"
1163 this.handleShowConfirmTransferCommunity
1165 aria-label={I18NextService.i18n.t("transfer_community")}
1167 {I18NextService.i18n.t("transfer_community")}
1172 className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
1173 aria-label={I18NextService.i18n.t("are_you_sure")}
1175 {I18NextService.i18n.t("are_you_sure")}
1178 className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
1179 aria-label={I18NextService.i18n.t("yes")}
1180 onClick={linkEvent(this, this.handleTransferCommunity)}
1182 {this.state.transferLoading ? (
1185 I18NextService.i18n.t("yes")
1189 className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1192 this.handleCancelShowConfirmTransferCommunity
1194 aria-label={I18NextService.i18n.t("no")}
1196 {I18NextService.i18n.t("no")}
1200 {/* Admins can ban from all, and appoint other admins */}
1201 {this.canAdmin_ && (
1203 {!this.creatorIsAdmin_ && (
1205 {!isBanned(post_view.creator) ? (
1207 className="btn btn-link btn-animate text-muted py-0"
1208 onClick={linkEvent(this, this.handleModBanShow)}
1209 aria-label={I18NextService.i18n.t("ban_from_site")}
1211 {I18NextService.i18n.t("ban_from_site")}
1215 className="btn btn-link btn-animate text-muted py-0"
1216 onClick={linkEvent(this, this.handleModBanSubmit)}
1217 aria-label={I18NextService.i18n.t("unban_from_site")}
1219 {this.state.banLoading ? (
1222 I18NextService.i18n.t("unban_from_site")
1227 className="btn btn-link btn-animate text-muted py-0"
1228 onClick={linkEvent(this, this.handlePurgePersonShow)}
1229 aria-label={I18NextService.i18n.t("purge_user")}
1231 {I18NextService.i18n.t("purge_user")}
1234 className="btn btn-link btn-animate text-muted py-0"
1235 onClick={linkEvent(this, this.handlePurgePostShow)}
1236 aria-label={I18NextService.i18n.t("purge_post")}
1238 {I18NextService.i18n.t("purge_post")}
1242 {!isBanned(post_view.creator) && post_view.creator.local && (
1244 className="btn btn-link btn-animate text-muted py-0"
1245 onClick={linkEvent(this, this.handleAddAdmin)}
1247 this.creatorIsAdmin_
1248 ? I18NextService.i18n.t("remove_as_admin")
1249 : I18NextService.i18n.t("appoint_as_admin")
1252 {this.state.addAdminLoading ? (
1254 ) : this.creatorIsAdmin_ ? (
1255 I18NextService.i18n.t("remove_as_admin")
1257 I18NextService.i18n.t("appoint_as_admin")
1268 removeAndBanDialogs() {
1269 const post = this.postView;
1270 const purgeTypeText =
1271 this.state.purgeType == PurgeType.Post
1272 ? I18NextService.i18n.t("purge_post")
1273 : `${I18NextService.i18n.t("purge")} ${post.creator.name}`;
1276 {this.state.showRemoveDialog && (
1278 className="form-inline"
1279 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1282 className="visually-hidden"
1283 htmlFor="post-listing-remove-reason"
1285 {I18NextService.i18n.t("reason")}
1289 id="post-listing-remove-reason"
1290 className="form-control me-2"
1291 placeholder={I18NextService.i18n.t("reason")}
1292 value={this.state.removeReason}
1293 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1297 className="btn btn-secondary"
1298 aria-label={I18NextService.i18n.t("remove_post")}
1300 {this.state.removeLoading ? (
1303 I18NextService.i18n.t("remove_post")
1308 {this.state.showBanDialog && (
1309 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1310 <div className="mb-3 row col-12">
1312 className="col-form-label"
1313 htmlFor="post-listing-ban-reason"
1315 {I18NextService.i18n.t("reason")}
1319 id="post-listing-ban-reason"
1320 className="form-control me-2"
1321 placeholder={I18NextService.i18n.t("reason")}
1322 value={this.state.banReason}
1323 onInput={linkEvent(this, this.handleModBanReasonChange)}
1325 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1326 {I18NextService.i18n.t("expires")}
1330 id={`mod-ban-expires`}
1331 className="form-control me-2"
1332 placeholder={I18NextService.i18n.t("number_of_days")}
1333 value={this.state.banExpireDays}
1334 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1336 <div className="input-group mb-3">
1337 <div className="form-check">
1339 className="form-check-input"
1340 id="mod-ban-remove-data"
1342 checked={this.state.removeData}
1343 onChange={linkEvent(this, this.handleModRemoveDataChange)}
1346 className="form-check-label"
1347 htmlFor="mod-ban-remove-data"
1348 title={I18NextService.i18n.t("remove_content_more")}
1350 {I18NextService.i18n.t("remove_content")}
1355 {/* TODO hold off on expires until later */}
1356 {/* <div class="mb-3 row"> */}
1357 {/* <label class="col-form-label">Expires</label> */}
1358 {/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1360 <div className="mb-3 row">
1363 className="btn btn-secondary"
1364 aria-label={I18NextService.i18n.t("ban")}
1366 {this.state.banLoading ? (
1370 {I18NextService.i18n.t("ban")} {post.creator.name}
1377 {this.state.showReportDialog && (
1379 className="form-inline"
1380 onSubmit={linkEvent(this, this.handleReportSubmit)}
1382 <label className="visually-hidden" htmlFor="post-report-reason">
1383 {I18NextService.i18n.t("reason")}
1387 id="post-report-reason"
1388 className="form-control me-2"
1389 placeholder={I18NextService.i18n.t("reason")}
1391 value={this.state.reportReason}
1392 onInput={linkEvent(this, this.handleReportReasonChange)}
1396 className="btn btn-secondary"
1397 aria-label={I18NextService.i18n.t("create_report")}
1399 {this.state.reportLoading ? (
1402 I18NextService.i18n.t("create_report")
1407 {this.state.showPurgeDialog && (
1409 className="form-inline"
1410 onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1413 <label className="visually-hidden" htmlFor="purge-reason">
1414 {I18NextService.i18n.t("reason")}
1419 className="form-control me-2"
1420 placeholder={I18NextService.i18n.t("reason")}
1421 value={this.state.purgeReason}
1422 onInput={linkEvent(this, this.handlePurgeReasonChange)}
1424 {this.state.purgeLoading ? (
1429 className="btn btn-secondary"
1430 aria-label={purgeTypeText}
1432 {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
1442 const post = this.postView.post;
1443 return post.thumbnail_url || (post.url && isImage(post.url)) ? (
1444 <div className="row">
1445 <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1446 {this.postTitleLine()}
1448 <div className="col-4">
1449 {/* Post body prev or thumbnail */}
1450 {!this.state.imageExpanded && this.thumbnail()}
1454 this.postTitleLine()
1459 const { body, id } = this.postView.post;
1461 return !this.showBody && body ? (
1462 <Link className="text-body mt-2 d-block" to={`/post/${id}`}>
1463 <div className="md-div mb-1 preview-lines">{body}</div>
1473 {/* The mobile view*/}
1474 <div className="d-block d-sm-none">
1475 <article className="row post-container">
1476 <div className="col-12">
1477 {this.createdLine()}
1479 {/* If it has a thumbnail, do a right aligned thumbnail */}
1480 {this.mobileThumbnail()}
1482 {/* Show a preview of the post body */}
1483 {this.showBodyPreview()}
1485 {this.commentsLine(true)}
1486 {this.userActionsLine()}
1487 {this.duplicatesLine()}
1488 {this.removeAndBanDialogs()}
1493 {/* The larger view*/}
1494 <div className="d-none d-sm-block">
1495 <article className="row post-container">
1496 {!this.props.viewOnly && this.voteBar()}
1497 <div className="col-sm-2 pe-0 post-media">
1498 <div className="">{this.thumbnail()}</div>
1500 <div className="col-12 col-sm-9">
1501 <div className="row">
1502 <div className="col-12">
1503 {this.postTitleLine()}
1504 {this.createdLine()}
1505 {this.showBodyPreview()}
1506 {this.commentsLine()}
1507 {this.duplicatesLine()}
1508 {this.userActionsLine()}
1509 {this.removeAndBanDialogs()}
1519 private get myPost(): boolean {
1521 this.postView.creator.id ==
1522 UserService.Instance.myUserInfo?.local_user_view.person.id
1525 handleEditClick(i: PostListing) {
1526 i.setState({ showEdit: true });
1529 handleEditCancel() {
1530 this.setState({ showEdit: false });
1533 // The actual editing is done in the receive for post
1534 handleEditPost(form: EditPost) {
1535 this.setState({ showEdit: false });
1536 this.props.onPostEdit(form);
1539 handleShare(i: PostListing) {
1540 const { name, body, id } = i.props.post_view.post;
1543 text: body?.slice(0, 50),
1544 url: `${getHttpBase()}/post/${id}`,
1548 handleShowReportDialog(i: PostListing) {
1549 i.setState({ showReportDialog: !i.state.showReportDialog });
1552 handleReportReasonChange(i: PostListing, event: any) {
1553 i.setState({ reportReason: event.target.value });
1556 handleReportSubmit(i: PostListing, event: any) {
1557 event.preventDefault();
1558 i.setState({ reportLoading: true });
1559 i.props.onPostReport({
1560 post_id: i.postView.post.id,
1561 reason: i.state.reportReason ?? "",
1562 auth: myAuthRequired(),
1566 handleBlockPersonClick(i: PostListing) {
1567 i.setState({ blockLoading: true });
1568 i.props.onBlockPerson({
1569 person_id: i.postView.creator.id,
1571 auth: myAuthRequired(),
1575 handleDeleteClick(i: PostListing) {
1576 i.setState({ deleteLoading: true });
1577 i.props.onDeletePost({
1578 post_id: i.postView.post.id,
1579 deleted: !i.postView.post.deleted,
1580 auth: myAuthRequired(),
1584 handleSavePostClick(i: PostListing) {
1585 i.setState({ saveLoading: true });
1586 i.props.onSavePost({
1587 post_id: i.postView.post.id,
1588 save: !i.postView.saved,
1589 auth: myAuthRequired(),
1593 get crossPostParams(): PostFormParams {
1594 const queryParams: PostFormParams = {};
1595 const { name, url } = this.postView.post;
1597 queryParams.name = name;
1600 queryParams.url = url;
1603 const crossPostBody = this.crossPostBody();
1604 if (crossPostBody) {
1605 queryParams.body = crossPostBody;
1611 crossPostBody(): string | undefined {
1612 const post = this.postView.post;
1613 const body = post.body;
1616 ? `${I18NextService.i18n.t("cross_posted_from")} ${
1618 }\n\n${body.replace(/^/gm, "> ")}`
1622 get showBody(): boolean {
1623 return this.props.showBody || this.state.showBody;
1626 handleModRemoveShow(i: PostListing) {
1628 showRemoveDialog: !i.state.showRemoveDialog,
1629 showBanDialog: false,
1633 handleModRemoveReasonChange(i: PostListing, event: any) {
1634 i.setState({ removeReason: event.target.value });
1637 handleModRemoveDataChange(i: PostListing, event: any) {
1638 i.setState({ removeData: event.target.checked });
1641 handleModRemoveSubmit(i: PostListing, event: any) {
1642 event.preventDefault();
1643 i.setState({ removeLoading: true });
1644 i.props.onRemovePost({
1645 post_id: i.postView.post.id,
1646 removed: !i.postView.post.removed,
1647 auth: myAuthRequired(),
1651 handleModLock(i: PostListing) {
1652 i.setState({ lockLoading: true });
1653 i.props.onLockPost({
1654 post_id: i.postView.post.id,
1655 locked: !i.postView.post.locked,
1656 auth: myAuthRequired(),
1660 handleModFeaturePostLocal(i: PostListing) {
1661 i.setState({ featureLocalLoading: true });
1662 i.props.onFeaturePost({
1663 post_id: i.postView.post.id,
1664 featured: !i.postView.post.featured_local,
1665 feature_type: "Local",
1666 auth: myAuthRequired(),
1670 handleModFeaturePostCommunity(i: PostListing) {
1671 i.setState({ featureCommunityLoading: true });
1672 i.props.onFeaturePost({
1673 post_id: i.postView.post.id,
1674 featured: !i.postView.post.featured_community,
1675 feature_type: "Community",
1676 auth: myAuthRequired(),
1680 handleModBanFromCommunityShow(i: PostListing) {
1682 showBanDialog: true,
1683 banType: BanType.Community,
1684 showRemoveDialog: false,
1688 handleModBanShow(i: PostListing) {
1690 showBanDialog: true,
1691 banType: BanType.Site,
1692 showRemoveDialog: false,
1696 handlePurgePersonShow(i: PostListing) {
1698 showPurgeDialog: true,
1699 purgeType: PurgeType.Person,
1700 showRemoveDialog: false,
1704 handlePurgePostShow(i: PostListing) {
1706 showPurgeDialog: true,
1707 purgeType: PurgeType.Post,
1708 showRemoveDialog: false,
1712 handlePurgeReasonChange(i: PostListing, event: any) {
1713 i.setState({ purgeReason: event.target.value });
1716 handlePurgeSubmit(i: PostListing, event: any) {
1717 event.preventDefault();
1718 i.setState({ purgeLoading: true });
1719 if (i.state.purgeType == PurgeType.Person) {
1720 i.props.onPurgePerson({
1721 person_id: i.postView.creator.id,
1722 reason: i.state.purgeReason,
1723 auth: myAuthRequired(),
1725 } else if (i.state.purgeType == PurgeType.Post) {
1726 i.props.onPurgePost({
1727 post_id: i.postView.post.id,
1728 reason: i.state.purgeReason,
1729 auth: myAuthRequired(),
1734 handleModBanReasonChange(i: PostListing, event: any) {
1735 i.setState({ banReason: event.target.value });
1738 handleModBanExpireDaysChange(i: PostListing, event: any) {
1739 i.setState({ banExpireDays: event.target.value });
1742 handleModBanFromCommunitySubmit(i: PostListing, event: any) {
1743 i.setState({ banType: BanType.Community });
1744 i.handleModBanBothSubmit(i, event);
1747 handleModBanSubmit(i: PostListing, event: any) {
1748 i.setState({ banType: BanType.Site });
1749 i.handleModBanBothSubmit(i, event);
1752 handleModBanBothSubmit(i: PostListing, event: any) {
1753 event.preventDefault();
1754 i.setState({ banLoading: true });
1756 const ban = !i.props.post_view.creator_banned_from_community;
1757 // If its an unban, restore all their data
1759 i.setState({ removeData: false });
1761 const person_id = i.props.post_view.creator.id;
1762 const remove_data = i.state.removeData;
1763 const reason = i.state.banReason;
1764 const expires = futureDaysToUnixTime(i.state.banExpireDays);
1766 if (i.state.banType == BanType.Community) {
1767 const community_id = i.postView.community.id;
1768 i.props.onBanPersonFromCommunity({
1775 auth: myAuthRequired(),
1778 i.props.onBanPerson({
1784 auth: myAuthRequired(),
1789 handleAddModToCommunity(i: PostListing) {
1790 i.setState({ addModLoading: true });
1791 i.props.onAddModToCommunity({
1792 community_id: i.postView.community.id,
1793 person_id: i.postView.creator.id,
1794 added: !i.creatorIsMod_,
1795 auth: myAuthRequired(),
1799 handleAddAdmin(i: PostListing) {
1800 i.setState({ addAdminLoading: true });
1801 i.props.onAddAdmin({
1802 person_id: i.postView.creator.id,
1803 added: !i.creatorIsAdmin_,
1804 auth: myAuthRequired(),
1808 handleShowConfirmTransferCommunity(i: PostListing) {
1809 i.setState({ showConfirmTransferCommunity: true });
1812 handleCancelShowConfirmTransferCommunity(i: PostListing) {
1813 i.setState({ showConfirmTransferCommunity: false });
1816 handleTransferCommunity(i: PostListing) {
1817 i.setState({ transferLoading: true });
1818 i.props.onTransferCommunity({
1819 community_id: i.postView.community.id,
1820 person_id: i.postView.creator.id,
1821 auth: myAuthRequired(),
1825 handleShowConfirmTransferSite(i: PostListing) {
1826 i.setState({ showConfirmTransferSite: true });
1829 handleCancelShowConfirmTransferSite(i: PostListing) {
1830 i.setState({ showConfirmTransferSite: false });
1833 handleImageExpandClick(i: PostListing, event: any) {
1834 event.preventDefault();
1835 i.setState({ imageExpanded: !i.state.imageExpanded });
1839 handleViewSource(i: PostListing) {
1840 i.setState({ viewSource: !i.state.viewSource });
1843 handleShowAdvanced(i: PostListing) {
1844 i.setState({ showAdvanced: !i.state.showAdvanced });
1848 handleShowMoreMobile(i: PostListing) {
1850 showMoreMobile: !i.state.showMoreMobile,
1851 showAdvanced: !i.state.showAdvanced,
1856 handleShowBody(i: PostListing) {
1857 i.setState({ showBody: !i.state.showBody });
1861 handleUpvote(i: PostListing) {
1862 i.setState({ upvoteLoading: true });
1863 i.props.onPostVote({
1864 post_id: i.postView.post.id,
1865 score: newVote(VoteType.Upvote, i.props.post_view.my_vote),
1866 auth: myAuthRequired(),
1870 handleDownvote(i: PostListing) {
1871 i.setState({ downvoteLoading: true });
1872 i.props.onPostVote({
1873 post_id: i.postView.post.id,
1874 score: newVote(VoteType.Downvote, i.props.post_view.my_vote),
1875 auth: myAuthRequired(),
1879 get pointsTippy(): string {
1880 const points = I18NextService.i18n.t("number_of_points", {
1881 count: Number(this.postView.counts.score),
1882 formattedCount: Number(this.postView.counts.score),
1885 const upvotes = I18NextService.i18n.t("number_of_upvotes", {
1886 count: Number(this.postView.counts.upvotes),
1887 formattedCount: Number(this.postView.counts.upvotes),
1890 const downvotes = I18NextService.i18n.t("number_of_downvotes", {
1891 count: Number(this.postView.counts.downvotes),
1892 formattedCount: Number(this.postView.counts.downvotes),
1895 return `${points} • ${upvotes} • ${downvotes}`;
1898 get canModOnSelf_(): boolean {
1900 this.postView.creator.id,
1901 this.props.moderators,
1908 get canMod_(): boolean {
1910 this.postView.creator.id,
1911 this.props.moderators,
1916 get canAdmin_(): boolean {
1917 return canAdmin(this.postView.creator.id, this.props.admins);
1920 get creatorIsMod_(): boolean {
1921 return isMod(this.postView.creator.id, this.props.moderators);
1924 get creatorIsAdmin_(): boolean {
1925 return isAdmin(this.postView.creator.id, this.props.admins);