1 import { myAuthRequired, newVote, showScores } from "@utils/app";
2 import { canShare, share } from "@utils/browser";
3 import { futureDaysToUnixTime, hostname, numToSI } from "@utils/helpers";
4 import { isImage, isVideo } from "@utils/media";
14 } from "@utils/roles";
15 import classNames from "classnames";
16 import { Component, linkEvent } from "inferno";
17 import { Link } from "inferno-router";
24 CommunityModeratorView,
39 } from "lemmy-js-client";
40 import { relTags } from "../../config";
41 import { getExternalHost, getHttpBase } from "../../env";
42 import { i18n } from "../../i18next";
43 import { BanType, PostFormParams, PurgeType, VoteType } from "../../interfaces";
44 import { mdNoImages, mdToHtml, mdToHtmlInline } from "../../markdown";
45 import { UserService } from "../../services";
46 import { setupTippy } from "../../tippy";
47 import { Icon, PurgeWarning, Spinner } from "../common/icon";
48 import { MomentTime } from "../common/moment-time";
49 import { PictrsImage } from "../common/pictrs-image";
50 import { CommunityLink } from "../community/community-link";
51 import { PersonListing } from "../person/person-listing";
52 import { MetadataCard } from "./metadata-card";
53 import { PostForm } from "./post-form";
55 interface PostListingState {
57 showRemoveDialog: boolean;
58 showPurgeDialog: boolean;
60 purgeType?: PurgeType;
61 purgeLoading: boolean;
62 removeReason?: string;
63 showBanDialog: boolean;
65 banExpireDays?: number;
68 showConfirmTransferSite: boolean;
69 showConfirmTransferCommunity: boolean;
70 imageExpanded: boolean;
72 showAdvanced: boolean;
73 showMoreMobile: boolean;
75 showReportDialog: boolean;
76 reportReason?: string;
77 upvoteLoading: boolean;
78 downvoteLoading: boolean;
79 reportLoading: boolean;
80 blockLoading: boolean;
82 deleteLoading: boolean;
83 removeLoading: boolean;
85 featureCommunityLoading: boolean;
86 featureLocalLoading: boolean;
88 addModLoading: boolean;
89 addAdminLoading: boolean;
90 transferLoading: boolean;
93 interface PostListingProps {
95 crossPosts?: PostView[];
96 moderators?: CommunityModeratorView[];
97 admins?: PersonView[];
98 allLanguages: Language[];
99 siteLanguages: number[];
100 showCommunity?: boolean;
103 enableDownvotes?: boolean;
104 enableNsfw?: boolean;
106 onPostEdit(form: EditPost): void;
107 onPostVote(form: CreatePostLike): void;
108 onPostReport(form: CreatePostReport): void;
109 onBlockPerson(form: BlockPerson): void;
110 onLockPost(form: LockPost): void;
111 onDeletePost(form: DeletePost): void;
112 onRemovePost(form: RemovePost): void;
113 onSavePost(form: SavePost): void;
114 onFeaturePost(form: FeaturePost): void;
115 onPurgePerson(form: PurgePerson): void;
116 onPurgePost(form: PurgePost): void;
117 onBanPersonFromCommunity(form: BanFromCommunity): void;
118 onBanPerson(form: BanPerson): void;
119 onAddModToCommunity(form: AddModToCommunity): void;
120 onAddAdmin(form: AddAdmin): void;
121 onTransferCommunity(form: TransferCommunity): void;
124 export class PostListing extends Component<PostListingProps, PostListingState> {
125 state: PostListingState = {
127 showRemoveDialog: false,
128 showPurgeDialog: false,
129 purgeType: PurgeType.Person,
130 showBanDialog: false,
131 banType: BanType.Community,
133 showConfirmTransferSite: false,
134 showConfirmTransferCommunity: false,
135 imageExpanded: false,
138 showMoreMobile: false,
140 showReportDialog: false,
141 upvoteLoading: false,
142 downvoteLoading: false,
144 reportLoading: false,
147 deleteLoading: false,
148 removeLoading: false,
150 featureCommunityLoading: false,
151 featureLocalLoading: false,
153 addModLoading: false,
154 addAdminLoading: false,
155 transferLoading: false,
158 constructor(props: any, context: any) {
159 super(props, context);
161 this.handleEditPost = this.handleEditPost.bind(this);
162 this.handleEditCancel = this.handleEditCancel.bind(this);
165 componentWillReceiveProps(nextProps: PostListingProps) {
166 if (this.props !== nextProps) {
168 upvoteLoading: false,
169 downvoteLoading: false,
171 reportLoading: false,
174 deleteLoading: false,
175 removeLoading: false,
177 featureCommunityLoading: false,
178 featureLocalLoading: false,
180 addModLoading: false,
181 addAdminLoading: false,
182 transferLoading: false,
183 imageExpanded: false,
188 get postView(): PostView {
189 return this.props.post_view;
193 const post = this.postView.post;
196 <div className="post-listing mt-2">
197 {!this.state.showEdit ? (
200 {this.state.imageExpanded && !this.props.hideImage && this.img}
201 {post.url && this.state.showBody && post.embed_title && (
202 <MetadataCard post={post} />
204 {this.showBody && this.body()}
208 post_view={this.postView}
209 crossPosts={this.props.crossPosts}
210 onEdit={this.handleEditPost}
211 onCancel={this.handleEditCancel}
212 enableNsfw={this.props.enableNsfw}
213 enableDownvotes={this.props.enableDownvotes}
214 allLanguages={this.props.allLanguages}
215 siteLanguages={this.props.siteLanguages}
223 const body = this.postView.post.body;
225 <article id="postContent" className="col-12 card my-2 p-2">
226 {this.state.viewSource ? (
229 <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
238 return this.imageSrc ? (
240 <div className="offset-sm-3 my-2 d-none d-sm-block">
241 <a href={this.imageSrc} className="d-inline-block">
242 <PictrsImage src={this.imageSrc} />
245 <div className="my-2 d-block d-sm-none">
247 className="d-inline-block"
248 onClick={linkEvent(this, this.handleImageExpandClick)}
250 <PictrsImage src={this.imageSrc} />
259 imgThumb(src: string) {
260 const post_view = this.postView;
266 nsfw={post_view.post.nsfw || post_view.community.nsfw}
271 get imageSrc(): string | undefined {
272 const post = this.postView.post;
273 const url = post.url;
274 const thumbnail = post.thumbnail_url;
276 if (url && isImage(url)) {
277 if (url.includes("pictrs")) {
279 } else if (thumbnail) {
284 } else if (thumbnail) {
292 const post = this.postView.post;
293 const url = post.url;
294 const thumbnail = post.thumbnail_url;
296 if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
300 className="text-body d-inline-block position-relative mb-2"
301 data-tippy-content={i18n.t("expand_here")}
302 onClick={linkEvent(this, this.handleImageExpandClick)}
303 aria-label={i18n.t("expand_here")}
305 {this.imgThumb(this.imageSrc)}
306 <Icon icon="image" classes="mini-overlay" />
309 } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
312 className="text-body d-inline-block position-relative mb-2"
317 {this.imgThumb(this.imageSrc)}
318 <Icon icon="external-link" classes="mini-overlay" />
322 if (!this.props.hideImage && isVideo(url)) {
324 <div className="embed-responsive embed-responsive-16by9">
330 className="embed-responsive-item"
332 <source src={url} type="video/mp4" />
338 <a className="text-body" href={url} title={url} rel={relTags}>
339 <div className="thumbnail rounded bg-light d-flex justify-content-center">
340 <Icon icon="external-link" classes="d-flex align-items-center" />
348 className="text-body"
349 to={`/post/${post.id}`}
350 title={i18n.t("comments")}
352 <div className="thumbnail rounded bg-light d-flex justify-content-center">
353 <Icon icon="message-square" classes="d-flex align-items-center" />
361 const post_view = this.postView;
363 <span className="small">
364 <PersonListing person={post_view.creator} />
365 {this.creatorIsMod_ && (
366 <span className="mx-1 badge text-bg-light">{i18n.t("mod")}</span>
368 {this.creatorIsAdmin_ && (
369 <span className="mx-1 badge text-bg-light">{i18n.t("admin")}</span>
371 {post_view.creator.bot_account && (
372 <span className="mx-1 badge text-bg-light">
373 {i18n.t("bot_account").toLowerCase()}
376 {this.props.showCommunity && (
379 {i18n.t("to")} <CommunityLink community={post_view.community} />
382 {post_view.post.language_id !== 0 && (
383 <span className="mx-1 badge text-bg-light">
385 this.props.allLanguages.find(
386 lang => lang.id === post_view.post.language_id
393 published={post_view.post.published}
394 updated={post_view.post.updated}
402 <div className={`vote-bar col-1 pe-0 small text-center`}>
404 className={`btn-animate btn btn-link p-0 ${
405 this.postView.my_vote == 1 ? "text-info" : "text-muted"
407 onClick={linkEvent(this, this.handleUpvote)}
408 data-tippy-content={i18n.t("upvote")}
409 aria-label={i18n.t("upvote")}
410 aria-pressed={this.postView.my_vote === 1}
412 {this.state.upvoteLoading ? (
415 <Icon icon="arrow-up1" classes="upvote" />
420 className={`unselectable pointer text-muted px-1 post-score`}
421 data-tippy-content={this.pointsTippy}
423 {numToSI(this.postView.counts.score)}
426 <div className="p-1"></div>
428 {this.props.enableDownvotes && (
430 className={`btn-animate btn btn-link p-0 ${
431 this.postView.my_vote == -1 ? "text-danger" : "text-muted"
433 onClick={linkEvent(this, this.handleDownvote)}
434 data-tippy-content={i18n.t("downvote")}
435 aria-label={i18n.t("downvote")}
436 aria-pressed={this.postView.my_vote === -1}
438 {this.state.downvoteLoading ? (
441 <Icon icon="arrow-down1" classes="downvote" />
450 const post = this.postView.post;
453 className={`d-inline ${
454 !post.featured_community && !post.featured_local
458 to={`/post/${post.id}`}
459 title={i18n.t("comments")}
463 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
470 const post = this.postView.post;
471 const url = post.url;
475 <div className="post-title overflow-hidden">
476 <h5 className="d-inline">
477 {url && this.props.showBody ? (
480 !post.featured_community && !post.featured_local
487 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
493 {(url && isImage(url)) ||
494 (post.thumbnail_url && (
496 className="btn btn-sm text-monospace text-muted d-inline-block"
497 data-tippy-content={i18n.t("expand_here")}
498 onClick={linkEvent(this, this.handleImageExpandClick)}
502 !this.state.imageExpanded ? "plus-square" : "minus-square"
504 classes="icon-inline"
509 <small className="ms-2 badge text-bg-secondary">
515 className="unselectable pointer ms-2 text-muted font-italic"
516 data-tippy-content={i18n.t("deleted")}
518 <Icon icon="trash" classes="icon-inline text-danger" />
523 className="unselectable pointer ms-2 text-muted font-italic"
524 data-tippy-content={i18n.t("locked")}
526 <Icon icon="lock" classes="icon-inline text-danger" />
529 {post.featured_community && (
531 className="unselectable pointer ms-2 text-muted font-italic"
532 data-tippy-content={i18n.t("featured_in_community")}
533 aria-label={i18n.t("featured_in_community")}
535 <Icon icon="pin" classes="icon-inline text-primary" />
538 {post.featured_local && (
540 className="unselectable pointer ms-2 text-muted font-italic"
541 data-tippy-content={i18n.t("featured_in_local")}
542 aria-label={i18n.t("featured_in_local")}
544 <Icon icon="pin" classes="icon-inline text-secondary" />
548 <small className="ms-2 badge text-bg-danger">
553 {url && this.urlLine()}
559 const post = this.postView.post;
560 const url = post.url;
563 <p className="d-flex text-muted align-items-center gap-1 small m-0">
564 {url && !(hostname(url) === getExternalHost()) && (
566 className="text-muted font-italic"
579 const dupes = this.props.crossPosts;
580 return dupes && dupes.length > 0 ? (
581 <ul className="list-inline mb-1 small text-muted">
583 <li className="list-inline-item me-2">{i18n.t("cross_posted_to")}</li>
585 <li key={pv.post.id} className="list-inline-item me-2">
586 <Link to={`/post/${pv.post.id}`}>
589 : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
600 commentsLine(mobile = false) {
601 const post = this.postView.post;
604 <div className="d-flex align-items-center justify-content-start flex-wrap text-muted">
605 {this.commentsButton}
608 className="btn btn-sm btn-animate text-muted py-0"
609 onClick={linkEvent(this, this.handleShare)}
612 <Icon icon="share" inline />
617 className="btn btn-sm btn-animate text-muted py-0"
618 title={i18n.t("link")}
621 <Icon icon="fedilink" inline />
624 {mobile && !this.props.viewOnly && this.mobileVotes}
625 {UserService.Instance.myUserInfo &&
626 !this.props.viewOnly &&
632 showPreviewButton() {
633 const post_view = this.postView;
634 const body = post_view.post.body;
638 className="btn btn-sm btn-animate text-muted py-0"
639 data-tippy-content={body && mdNoImages.render(body)}
640 data-tippy-allowHtml={true}
641 onClick={linkEvent(this, this.handleShowBody)}
645 classes={classNames("icon-inline me-1", {
646 "text-success": this.state.showBody,
654 // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
655 // Possible enhancement: Make each button a component.
656 const post_view = this.postView;
657 const post = post_view.post;
662 {this.crossPostButton}
665 * If there is a URL, or if the post has a body and we were told not to
666 * show the body, show the MetadataCard/body toggle.
668 {(post.url || (post.body && !this.props.showBody)) &&
669 this.showPreviewButton()}
671 {this.showBody && post_view.post.body && this.viewSourceButton}
673 <div className="dropdown">
675 className="btn btn-sm btn-animate text-muted py-0 dropdown-toggle"
676 onClick={linkEvent(this, this.handleShowAdvanced)}
677 data-tippy-content={i18n.t("more")}
678 data-bs-toggle="dropdown"
679 aria-expanded="false"
680 aria-controls="advancedButtonsDropdown"
681 aria-label={i18n.t("more")}
683 <Icon icon="more-vertical" inline />
686 <ul className="dropdown-menu" id="advancedButtonsDropdown">
689 <li>{this.reportButton}</li>
690 <li>{this.blockButton}</li>
694 <li>{this.editButton}</li>
695 <li>{this.deleteButton}</li>
699 {/* Any mod can do these, not limited to hierarchy*/}
700 {(amMod(this.props.moderators) || amAdmin()) && (
703 <hr className="dropdown-divider" />
705 <li>{this.lockButton}</li>
706 {this.featureButtons}
710 {(this.canMod_ || this.canAdmin_) && (
711 <li>{this.modRemoveButton}</li>
719 get commentsButton() {
720 const post_view = this.postView;
721 const title = i18n.t("number_of_comments", {
722 count: Number(post_view.counts.comments),
723 formattedCount: Number(post_view.counts.comments),
728 className="btn btn-link btn-sm text-muted ps-0"
730 to={`/post/${post_view.post.id}?scrollToComments=true`}
731 data-tippy-content={title}
733 <Icon icon="message-square" classes="me-1" inline />
734 {post_view.counts.comments}
735 {this.unreadCount && (
736 <span className="text-muted fst-italic">
737 ({this.unreadCount} {i18n.t("new")})
744 get unreadCount(): number | undefined {
745 const pv = this.postView;
746 return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
748 : pv.unread_comments;
753 const tippy = showScores()
754 ? { "data-tippy-content": this.pointsTippy }
760 className={`btn-animate btn py-0 px-1 ${
761 this.postView.my_vote === 1 ? "text-info" : "text-muted"
764 onClick={linkEvent(this, this.handleUpvote)}
765 aria-label={i18n.t("upvote")}
766 aria-pressed={this.postView.my_vote === 1}
768 {this.state.upvoteLoading ? (
772 <Icon icon="arrow-up1" classes="icon-inline small" />
774 <span className="ms-2">
775 {numToSI(this.postView.counts.upvotes)}
781 {this.props.enableDownvotes && (
783 className={`ms-2 btn-animate btn py-0 px-1 ${
784 this.postView.my_vote === -1 ? "text-danger" : "text-muted"
786 onClick={linkEvent(this, this.handleDownvote)}
788 aria-label={i18n.t("downvote")}
789 aria-pressed={this.postView.my_vote === -1}
791 {this.state.downvoteLoading ? (
795 <Icon icon="arrow-down1" classes="icon-inline small" />
798 className={classNames("ms-2", {
799 invisible: this.postView.counts.downvotes === 0,
802 {numToSI(this.postView.counts.downvotes)}
815 const saved = this.postView.saved;
816 const label = saved ? i18n.t("unsave") : i18n.t("save");
819 className="btn btn-sm btn-animate text-muted py-0"
820 onClick={linkEvent(this, this.handleSavePostClick)}
821 data-tippy-content={label}
824 {this.state.saveLoading ? (
829 classes={classNames({ "text-warning": saved })}
837 get crossPostButton() {
840 className="btn btn-sm btn-animate text-muted py-0"
842 /* Empty string properties are required to satisfy type*/
843 pathname: "/create_post",
844 state: { ...this.crossPostParams },
849 title={i18n.t("cross_post")}
850 data-tippy-content={i18n.t("cross_post")}
851 aria-label={i18n.t("cross_post")}
853 <Icon icon="copy" inline />
861 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
862 onClick={linkEvent(this, this.handleShowReportDialog)}
863 aria-label={i18n.t("show_report_dialog")}
865 <Icon classes="me-1" icon="flag" inline />
866 {i18n.t("create_report")}
874 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
875 onClick={linkEvent(this, this.handleBlockPersonClick)}
876 aria-label={i18n.t("block_user")}
878 {this.state.blockLoading ? (
881 <Icon classes="me-1" icon="slash" inline />
883 {i18n.t("block_user")}
891 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
892 onClick={linkEvent(this, this.handleEditClick)}
893 aria-label={i18n.t("edit")}
895 <Icon classes="me-1" icon="edit" inline />
902 const deleted = this.postView.post.deleted;
903 const label = !deleted ? i18n.t("delete") : i18n.t("restore");
906 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
907 onClick={linkEvent(this, this.handleDeleteClick)}
910 {this.state.deleteLoading ? (
916 classes={classNames("me-1", { "text-danger": deleted })}
926 get viewSourceButton() {
929 className="btn btn-sm btn-animate text-muted py-0"
930 onClick={linkEvent(this, this.handleViewSource)}
931 data-tippy-content={i18n.t("view_source")}
932 aria-label={i18n.t("view_source")}
936 classes={classNames({ "text-success": this.state.viewSource })}
944 const locked = this.postView.post.locked;
945 const label = locked ? i18n.t("unlock") : i18n.t("lock");
948 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
949 onClick={linkEvent(this, this.handleModLock)}
952 {this.state.lockLoading ? (
958 classes={classNames("me-1", { "text-danger": locked })}
968 get featureButtons() {
969 const featuredCommunity = this.postView.post.featured_community;
970 const labelCommunity = featuredCommunity
971 ? i18n.t("unfeature_from_community")
972 : i18n.t("feature_in_community");
974 const featuredLocal = this.postView.post.featured_local;
975 const labelLocal = featuredLocal
976 ? i18n.t("unfeature_from_local")
977 : i18n.t("feature_in_local");
982 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
983 onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
984 data-tippy-content={labelCommunity}
985 aria-label={labelCommunity}
987 {this.state.featureCommunityLoading ? (
993 classes={classNames("me-1", {
994 "text-success": featuredCommunity,
998 {i18n.t("community")}
1006 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
1007 onClick={linkEvent(this, this.handleModFeaturePostLocal)}
1008 data-tippy-content={labelLocal}
1009 aria-label={labelLocal}
1011 {this.state.featureLocalLoading ? (
1017 classes={classNames("me-1", {
1018 "text-success": featuredLocal,
1032 get modRemoveButton() {
1033 const removed = this.postView.post.removed;
1036 className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
1039 !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
1042 {/* TODO: Find an icon for this. */}
1043 {this.state.removeLoading ? (
1055 * Mod/Admin actions to be taken against the author.
1059 const post_view = this.postView;
1061 this.state.showAdvanced && (
1062 <div className="mt-3">
1065 {!this.creatorIsMod_ &&
1066 (!post_view.creator_banned_from_community ? (
1068 className="btn btn-link btn-animate text-muted py-0"
1071 this.handleModBanFromCommunityShow
1073 aria-label={i18n.t("ban_from_community")}
1075 {i18n.t("ban_from_community")}
1079 className="btn btn-link btn-animate text-muted py-0"
1082 this.handleModBanFromCommunitySubmit
1084 aria-label={i18n.t("unban")}
1086 {this.state.banLoading ? <Spinner /> : i18n.t("unban")}
1089 {!post_view.creator_banned_from_community && (
1091 className="btn btn-link btn-animate text-muted py-0"
1092 onClick={linkEvent(this, this.handleAddModToCommunity)}
1095 ? i18n.t("remove_as_mod")
1096 : i18n.t("appoint_as_mod")
1099 {this.state.addModLoading ? (
1101 ) : this.creatorIsMod_ ? (
1102 i18n.t("remove_as_mod")
1104 i18n.t("appoint_as_mod")
1110 {/* Community creators and admins can transfer community to another mod */}
1111 {(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
1113 this.creatorIsMod_ &&
1114 (!this.state.showConfirmTransferCommunity ? (
1116 className="btn btn-link btn-animate text-muted py-0"
1119 this.handleShowConfirmTransferCommunity
1121 aria-label={i18n.t("transfer_community")}
1123 {i18n.t("transfer_community")}
1128 className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
1129 aria-label={i18n.t("are_you_sure")}
1131 {i18n.t("are_you_sure")}
1134 className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
1135 aria-label={i18n.t("yes")}
1136 onClick={linkEvent(this, this.handleTransferCommunity)}
1138 {this.state.transferLoading ? <Spinner /> : i18n.t("yes")}
1141 className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1144 this.handleCancelShowConfirmTransferCommunity
1146 aria-label={i18n.t("no")}
1152 {/* Admins can ban from all, and appoint other admins */}
1153 {this.canAdmin_ && (
1155 {!this.creatorIsAdmin_ && (
1157 {!isBanned(post_view.creator) ? (
1159 className="btn btn-link btn-animate text-muted py-0"
1160 onClick={linkEvent(this, this.handleModBanShow)}
1161 aria-label={i18n.t("ban_from_site")}
1163 {i18n.t("ban_from_site")}
1167 className="btn btn-link btn-animate text-muted py-0"
1168 onClick={linkEvent(this, this.handleModBanSubmit)}
1169 aria-label={i18n.t("unban_from_site")}
1171 {this.state.banLoading ? (
1174 i18n.t("unban_from_site")
1179 className="btn btn-link btn-animate text-muted py-0"
1180 onClick={linkEvent(this, this.handlePurgePersonShow)}
1181 aria-label={i18n.t("purge_user")}
1183 {i18n.t("purge_user")}
1186 className="btn btn-link btn-animate text-muted py-0"
1187 onClick={linkEvent(this, this.handlePurgePostShow)}
1188 aria-label={i18n.t("purge_post")}
1190 {i18n.t("purge_post")}
1194 {!isBanned(post_view.creator) && post_view.creator.local && (
1196 className="btn btn-link btn-animate text-muted py-0"
1197 onClick={linkEvent(this, this.handleAddAdmin)}
1199 this.creatorIsAdmin_
1200 ? i18n.t("remove_as_admin")
1201 : i18n.t("appoint_as_admin")
1204 {this.state.addAdminLoading ? (
1206 ) : this.creatorIsAdmin_ ? (
1207 i18n.t("remove_as_admin")
1209 i18n.t("appoint_as_admin")
1220 removeAndBanDialogs() {
1221 const post = this.postView;
1222 const purgeTypeText =
1223 this.state.purgeType == PurgeType.Post
1224 ? i18n.t("purge_post")
1225 : `${i18n.t("purge")} ${post.creator.name}`;
1228 {this.state.showRemoveDialog && (
1230 className="form-inline"
1231 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1234 className="visually-hidden"
1235 htmlFor="post-listing-remove-reason"
1241 id="post-listing-remove-reason"
1242 className="form-control me-2"
1243 placeholder={i18n.t("reason")}
1244 value={this.state.removeReason}
1245 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1249 className="btn btn-secondary"
1250 aria-label={i18n.t("remove_post")}
1252 {this.state.removeLoading ? <Spinner /> : i18n.t("remove_post")}
1256 {this.state.showBanDialog && (
1257 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1258 <div className="mb-3 row col-12">
1260 className="col-form-label"
1261 htmlFor="post-listing-ban-reason"
1267 id="post-listing-ban-reason"
1268 className="form-control me-2"
1269 placeholder={i18n.t("reason")}
1270 value={this.state.banReason}
1271 onInput={linkEvent(this, this.handleModBanReasonChange)}
1273 <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1278 id={`mod-ban-expires`}
1279 className="form-control me-2"
1280 placeholder={i18n.t("number_of_days")}
1281 value={this.state.banExpireDays}
1282 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1284 <div className="input-group mb-3">
1285 <div className="form-check">
1287 className="form-check-input"
1288 id="mod-ban-remove-data"
1290 checked={this.state.removeData}
1291 onChange={linkEvent(this, this.handleModRemoveDataChange)}
1294 className="form-check-label"
1295 htmlFor="mod-ban-remove-data"
1296 title={i18n.t("remove_content_more")}
1298 {i18n.t("remove_content")}
1303 {/* TODO hold off on expires until later */}
1304 {/* <div class="mb-3 row"> */}
1305 {/* <label class="col-form-label">Expires</label> */}
1306 {/* <input type="date" class="form-control me-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1308 <div className="mb-3 row">
1311 className="btn btn-secondary"
1312 aria-label={i18n.t("ban")}
1314 {this.state.banLoading ? (
1318 {i18n.t("ban")} {post.creator.name}
1325 {this.state.showReportDialog && (
1327 className="form-inline"
1328 onSubmit={linkEvent(this, this.handleReportSubmit)}
1330 <label className="visually-hidden" htmlFor="post-report-reason">
1335 id="post-report-reason"
1336 className="form-control me-2"
1337 placeholder={i18n.t("reason")}
1339 value={this.state.reportReason}
1340 onInput={linkEvent(this, this.handleReportReasonChange)}
1344 className="btn btn-secondary"
1345 aria-label={i18n.t("create_report")}
1347 {this.state.reportLoading ? <Spinner /> : i18n.t("create_report")}
1351 {this.state.showPurgeDialog && (
1353 className="form-inline"
1354 onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1357 <label className="visually-hidden" htmlFor="purge-reason">
1363 className="form-control me-2"
1364 placeholder={i18n.t("reason")}
1365 value={this.state.purgeReason}
1366 onInput={linkEvent(this, this.handlePurgeReasonChange)}
1368 {this.state.purgeLoading ? (
1373 className="btn btn-secondary"
1374 aria-label={purgeTypeText}
1376 {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
1386 const post = this.postView.post;
1387 return post.thumbnail_url || (post.url && isImage(post.url)) ? (
1388 <div className="row">
1389 <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1390 {this.postTitleLine()}
1392 <div className="col-4">
1393 {/* Post body prev or thumbnail */}
1394 {!this.state.imageExpanded && this.thumbnail()}
1398 this.postTitleLine()
1403 const { body, id } = this.postView.post;
1405 return !this.showBody && body ? (
1406 <Link className="text-body mt-2 d-block" to={`/post/${id}`}>
1407 <div className="md-div mb-1 preview-lines">{body}</div>
1417 {/* The mobile view*/}
1418 <div className="d-block d-sm-none">
1419 <article className="row post-container">
1420 <div className="col-12">
1421 {this.createdLine()}
1423 {/* If it has a thumbnail, do a right aligned thumbnail */}
1424 {this.mobileThumbnail()}
1426 {/* Show a preview of the post body */}
1427 {this.showBodyPreview()}
1429 {this.commentsLine(true)}
1430 {this.userActionsLine()}
1431 {this.duplicatesLine()}
1432 {this.removeAndBanDialogs()}
1437 {/* The larger view*/}
1438 <div className="d-none d-sm-block">
1439 <article className="row post-container">
1440 {!this.props.viewOnly && this.voteBar()}
1441 <div className="col-sm-2 pe-0 post-media">
1442 <div className="">{this.thumbnail()}</div>
1444 <div className="col-12 col-sm-9">
1445 <div className="row">
1446 <div className="col-12">
1447 {this.postTitleLine()}
1448 {this.createdLine()}
1449 {this.showBodyPreview()}
1450 {this.commentsLine()}
1451 {this.duplicatesLine()}
1452 {this.userActionsLine()}
1453 {this.removeAndBanDialogs()}
1463 private get myPost(): boolean {
1465 this.postView.creator.id ==
1466 UserService.Instance.myUserInfo?.local_user_view.person.id
1469 handleEditClick(i: PostListing) {
1470 i.setState({ showEdit: true });
1473 handleEditCancel() {
1474 this.setState({ showEdit: false });
1477 // The actual editing is done in the receive for post
1478 handleEditPost(form: EditPost) {
1479 this.setState({ showEdit: false });
1480 this.props.onPostEdit(form);
1483 handleShare(i: PostListing) {
1484 const { name, body, id } = i.props.post_view.post;
1487 text: body?.slice(0, 50),
1488 url: `${getHttpBase()}/post/${id}`,
1492 handleShowReportDialog(i: PostListing) {
1493 i.setState({ showReportDialog: !i.state.showReportDialog });
1496 handleReportReasonChange(i: PostListing, event: any) {
1497 i.setState({ reportReason: event.target.value });
1500 handleReportSubmit(i: PostListing, event: any) {
1501 event.preventDefault();
1502 i.setState({ reportLoading: true });
1503 i.props.onPostReport({
1504 post_id: i.postView.post.id,
1505 reason: i.state.reportReason ?? "",
1506 auth: myAuthRequired(),
1510 handleBlockPersonClick(i: PostListing) {
1511 i.setState({ blockLoading: true });
1512 i.props.onBlockPerson({
1513 person_id: i.postView.creator.id,
1515 auth: myAuthRequired(),
1519 handleDeleteClick(i: PostListing) {
1520 i.setState({ deleteLoading: true });
1521 i.props.onDeletePost({
1522 post_id: i.postView.post.id,
1523 deleted: !i.postView.post.deleted,
1524 auth: myAuthRequired(),
1528 handleSavePostClick(i: PostListing) {
1529 i.setState({ saveLoading: true });
1530 i.props.onSavePost({
1531 post_id: i.postView.post.id,
1532 save: !i.postView.saved,
1533 auth: myAuthRequired(),
1537 get crossPostParams(): PostFormParams {
1538 const queryParams: PostFormParams = {};
1539 const { name, url } = this.postView.post;
1541 queryParams.name = name;
1544 queryParams.url = url;
1547 const crossPostBody = this.crossPostBody();
1548 if (crossPostBody) {
1549 queryParams.body = crossPostBody;
1555 crossPostBody(): string | undefined {
1556 const post = this.postView.post;
1557 const body = post.body;
1560 ? `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${body.replace(
1567 get showBody(): boolean {
1568 return this.props.showBody || this.state.showBody;
1571 handleModRemoveShow(i: PostListing) {
1573 showRemoveDialog: !i.state.showRemoveDialog,
1574 showBanDialog: false,
1578 handleModRemoveReasonChange(i: PostListing, event: any) {
1579 i.setState({ removeReason: event.target.value });
1582 handleModRemoveDataChange(i: PostListing, event: any) {
1583 i.setState({ removeData: event.target.checked });
1586 handleModRemoveSubmit(i: PostListing, event: any) {
1587 event.preventDefault();
1588 i.setState({ removeLoading: true });
1589 i.props.onRemovePost({
1590 post_id: i.postView.post.id,
1591 removed: !i.postView.post.removed,
1592 auth: myAuthRequired(),
1596 handleModLock(i: PostListing) {
1597 i.setState({ lockLoading: true });
1598 i.props.onLockPost({
1599 post_id: i.postView.post.id,
1600 locked: !i.postView.post.locked,
1601 auth: myAuthRequired(),
1605 handleModFeaturePostLocal(i: PostListing) {
1606 i.setState({ featureLocalLoading: true });
1607 i.props.onFeaturePost({
1608 post_id: i.postView.post.id,
1609 featured: !i.postView.post.featured_local,
1610 feature_type: "Local",
1611 auth: myAuthRequired(),
1615 handleModFeaturePostCommunity(i: PostListing) {
1616 i.setState({ featureCommunityLoading: true });
1617 i.props.onFeaturePost({
1618 post_id: i.postView.post.id,
1619 featured: !i.postView.post.featured_community,
1620 feature_type: "Community",
1621 auth: myAuthRequired(),
1625 handleModBanFromCommunityShow(i: PostListing) {
1627 showBanDialog: true,
1628 banType: BanType.Community,
1629 showRemoveDialog: false,
1633 handleModBanShow(i: PostListing) {
1635 showBanDialog: true,
1636 banType: BanType.Site,
1637 showRemoveDialog: false,
1641 handlePurgePersonShow(i: PostListing) {
1643 showPurgeDialog: true,
1644 purgeType: PurgeType.Person,
1645 showRemoveDialog: false,
1649 handlePurgePostShow(i: PostListing) {
1651 showPurgeDialog: true,
1652 purgeType: PurgeType.Post,
1653 showRemoveDialog: false,
1657 handlePurgeReasonChange(i: PostListing, event: any) {
1658 i.setState({ purgeReason: event.target.value });
1661 handlePurgeSubmit(i: PostListing, event: any) {
1662 event.preventDefault();
1663 i.setState({ purgeLoading: true });
1664 if (i.state.purgeType == PurgeType.Person) {
1665 i.props.onPurgePerson({
1666 person_id: i.postView.creator.id,
1667 reason: i.state.purgeReason,
1668 auth: myAuthRequired(),
1670 } else if (i.state.purgeType == PurgeType.Post) {
1671 i.props.onPurgePost({
1672 post_id: i.postView.post.id,
1673 reason: i.state.purgeReason,
1674 auth: myAuthRequired(),
1679 handleModBanReasonChange(i: PostListing, event: any) {
1680 i.setState({ banReason: event.target.value });
1683 handleModBanExpireDaysChange(i: PostListing, event: any) {
1684 i.setState({ banExpireDays: event.target.value });
1687 handleModBanFromCommunitySubmit(i: PostListing, event: any) {
1688 i.setState({ banType: BanType.Community });
1689 i.handleModBanBothSubmit(i, event);
1692 handleModBanSubmit(i: PostListing, event: any) {
1693 i.setState({ banType: BanType.Site });
1694 i.handleModBanBothSubmit(i, event);
1697 handleModBanBothSubmit(i: PostListing, event: any) {
1698 event.preventDefault();
1699 i.setState({ banLoading: true });
1701 const ban = !i.props.post_view.creator_banned_from_community;
1702 // If its an unban, restore all their data
1704 i.setState({ removeData: false });
1706 const person_id = i.props.post_view.creator.id;
1707 const remove_data = i.state.removeData;
1708 const reason = i.state.banReason;
1709 const expires = futureDaysToUnixTime(i.state.banExpireDays);
1711 if (i.state.banType == BanType.Community) {
1712 const community_id = i.postView.community.id;
1713 i.props.onBanPersonFromCommunity({
1720 auth: myAuthRequired(),
1723 i.props.onBanPerson({
1729 auth: myAuthRequired(),
1734 handleAddModToCommunity(i: PostListing) {
1735 i.setState({ addModLoading: true });
1736 i.props.onAddModToCommunity({
1737 community_id: i.postView.community.id,
1738 person_id: i.postView.creator.id,
1739 added: !i.creatorIsMod_,
1740 auth: myAuthRequired(),
1744 handleAddAdmin(i: PostListing) {
1745 i.setState({ addAdminLoading: true });
1746 i.props.onAddAdmin({
1747 person_id: i.postView.creator.id,
1748 added: !i.creatorIsAdmin_,
1749 auth: myAuthRequired(),
1753 handleShowConfirmTransferCommunity(i: PostListing) {
1754 i.setState({ showConfirmTransferCommunity: true });
1757 handleCancelShowConfirmTransferCommunity(i: PostListing) {
1758 i.setState({ showConfirmTransferCommunity: false });
1761 handleTransferCommunity(i: PostListing) {
1762 i.setState({ transferLoading: true });
1763 i.props.onTransferCommunity({
1764 community_id: i.postView.community.id,
1765 person_id: i.postView.creator.id,
1766 auth: myAuthRequired(),
1770 handleShowConfirmTransferSite(i: PostListing) {
1771 i.setState({ showConfirmTransferSite: true });
1774 handleCancelShowConfirmTransferSite(i: PostListing) {
1775 i.setState({ showConfirmTransferSite: false });
1778 handleImageExpandClick(i: PostListing, event: any) {
1779 event.preventDefault();
1780 i.setState({ imageExpanded: !i.state.imageExpanded });
1784 handleViewSource(i: PostListing) {
1785 i.setState({ viewSource: !i.state.viewSource });
1788 handleShowAdvanced(i: PostListing) {
1789 i.setState({ showAdvanced: !i.state.showAdvanced });
1793 handleShowMoreMobile(i: PostListing) {
1795 showMoreMobile: !i.state.showMoreMobile,
1796 showAdvanced: !i.state.showAdvanced,
1801 handleShowBody(i: PostListing) {
1802 i.setState({ showBody: !i.state.showBody });
1806 handleUpvote(i: PostListing) {
1807 i.setState({ upvoteLoading: true });
1808 i.props.onPostVote({
1809 post_id: i.postView.post.id,
1810 score: newVote(VoteType.Upvote, i.props.post_view.my_vote),
1811 auth: myAuthRequired(),
1815 handleDownvote(i: PostListing) {
1816 i.setState({ downvoteLoading: true });
1817 i.props.onPostVote({
1818 post_id: i.postView.post.id,
1819 score: newVote(VoteType.Downvote, i.props.post_view.my_vote),
1820 auth: myAuthRequired(),
1824 get pointsTippy(): string {
1825 const points = i18n.t("number_of_points", {
1826 count: Number(this.postView.counts.score),
1827 formattedCount: Number(this.postView.counts.score),
1830 const upvotes = i18n.t("number_of_upvotes", {
1831 count: Number(this.postView.counts.upvotes),
1832 formattedCount: Number(this.postView.counts.upvotes),
1835 const downvotes = i18n.t("number_of_downvotes", {
1836 count: Number(this.postView.counts.downvotes),
1837 formattedCount: Number(this.postView.counts.downvotes),
1840 return `${points} • ${upvotes} • ${downvotes}`;
1843 get canModOnSelf_(): boolean {
1845 this.postView.creator.id,
1846 this.props.moderators,
1853 get canMod_(): boolean {
1855 this.postView.creator.id,
1856 this.props.moderators,
1861 get canAdmin_(): boolean {
1862 return canAdmin(this.postView.creator.id, this.props.admins);
1865 get creatorIsMod_(): boolean {
1866 return isMod(this.postView.creator.id, this.props.moderators);
1869 get creatorIsAdmin_(): boolean {
1870 return isAdmin(this.postView.creator.id, this.props.admins);