import { getRoleLabelPill, myAuthRequired } from "@utils/app"; import { canShare, share } from "@utils/browser"; import { getExternalHost, getHttpBase } from "@utils/env"; import { capitalizeFirstLetter, futureDaysToUnixTime, hostname, } from "@utils/helpers"; import { isImage, isVideo } from "@utils/media"; import { amAdmin, amCommunityCreator, amMod, canAdmin, canMod, isAdmin, isBanned, isMod, } from "@utils/roles"; import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { AddAdmin, AddModToCommunity, BanFromCommunity, BanPerson, BlockPerson, CommunityModeratorView, CreatePostLike, CreatePostReport, DeletePost, EditPost, FeaturePost, Language, LockPost, PersonView, PostView, PurgePerson, PurgePost, RemovePost, SavePost, TransferCommunity, } from "lemmy-js-client"; import { relTags } from "../../config"; import { BanType, PostFormParams, PurgeType, VoteContentType, } from "../../interfaces"; import { mdToHtml, mdToHtmlInline } from "../../markdown"; import { I18NextService, UserService } from "../../services"; import { setupTippy } from "../../tippy"; import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { MomentTime } from "../common/moment-time"; import { PictrsImage } from "../common/pictrs-image"; import { VoteButtons, VoteButtonsCompact } from "../common/vote-buttons"; import { CommunityLink } from "../community/community-link"; import { PersonListing } from "../person/person-listing"; import { MetadataCard } from "./metadata-card"; import { PostForm } from "./post-form"; interface PostListingState { showEdit: boolean; showRemoveDialog: boolean; showPurgeDialog: boolean; purgeReason?: string; purgeType?: PurgeType; purgeLoading: boolean; removeReason?: string; showBanDialog: boolean; banReason?: string; banExpireDays?: number; banType?: BanType; removeData?: boolean; showConfirmTransferSite: boolean; showConfirmTransferCommunity: boolean; imageExpanded: boolean; viewSource: boolean; showAdvanced: boolean; showMoreMobile: boolean; showBody: boolean; showReportDialog: boolean; reportReason?: string; reportLoading: boolean; blockLoading: boolean; lockLoading: boolean; deleteLoading: boolean; removeLoading: boolean; saveLoading: boolean; featureCommunityLoading: boolean; featureLocalLoading: boolean; banLoading: boolean; addModLoading: boolean; addAdminLoading: boolean; transferLoading: boolean; } interface PostListingProps { post_view: PostView; crossPosts?: PostView[]; moderators?: CommunityModeratorView[]; admins?: PersonView[]; allLanguages: Language[]; siteLanguages: number[]; showCommunity?: boolean; /** * Controls whether to show both the body *and* the metadata preview card */ showBody?: boolean; hideImage?: boolean; enableDownvotes?: boolean; enableNsfw?: boolean; viewOnly?: boolean; onPostEdit(form: EditPost): void; onPostVote(form: CreatePostLike): void; onPostReport(form: CreatePostReport): void; onBlockPerson(form: BlockPerson): void; onLockPost(form: LockPost): void; onDeletePost(form: DeletePost): void; onRemovePost(form: RemovePost): void; onSavePost(form: SavePost): void; onFeaturePost(form: FeaturePost): void; onPurgePerson(form: PurgePerson): void; onPurgePost(form: PurgePost): void; onBanPersonFromCommunity(form: BanFromCommunity): void; onBanPerson(form: BanPerson): void; onAddModToCommunity(form: AddModToCommunity): void; onAddAdmin(form: AddAdmin): void; onTransferCommunity(form: TransferCommunity): void; } export class PostListing extends Component { state: PostListingState = { showEdit: false, showRemoveDialog: false, showPurgeDialog: false, purgeType: PurgeType.Person, showBanDialog: false, banType: BanType.Community, removeData: false, showConfirmTransferSite: false, showConfirmTransferCommunity: false, imageExpanded: false, viewSource: false, showAdvanced: false, showMoreMobile: false, showBody: false, showReportDialog: false, purgeLoading: false, reportLoading: false, blockLoading: false, lockLoading: false, deleteLoading: false, removeLoading: false, saveLoading: false, featureCommunityLoading: false, featureLocalLoading: false, banLoading: false, addModLoading: false, addAdminLoading: false, transferLoading: false, }; constructor(props: any, context: any) { super(props, context); this.handleEditPost = this.handleEditPost.bind(this); this.handleEditCancel = this.handleEditCancel.bind(this); } componentWillReceiveProps(nextProps: PostListingProps) { if (this.props !== nextProps) { this.setState({ purgeLoading: false, reportLoading: false, blockLoading: false, lockLoading: false, deleteLoading: false, removeLoading: false, saveLoading: false, featureCommunityLoading: false, featureLocalLoading: false, banLoading: false, addModLoading: false, addAdminLoading: false, transferLoading: false, }); } } get postView(): PostView { return this.props.post_view; } render() { const post = this.postView.post; return (
{!this.state.showEdit ? ( <> {this.listing()} {this.state.imageExpanded && !this.props.hideImage && this.img} {this.showBody && post.url && post.embed_title && ( )} {this.showBody && this.body()} ) : ( )}
); } body() { const body = this.postView.post.body; return body ? (
{this.state.viewSource ? (
{body}
) : (
)}
) : ( <> ); } get img() { if (this.imageSrc) { return ( <>
); } const { post } = this.postView; const { url } = post; // if direct video link if (url && isVideo(url)) { return (
); } // if embedded video link if (url && post.embed_video_url) { return (
); } return <>; } imgThumb(src: string) { const post_view = this.postView; return ( ); } get imageSrc(): string | undefined { const post = this.postView.post; const url = post.url; const thumbnail = post.thumbnail_url; if (url && isImage(url)) { if (url.includes("pictrs")) { return url; } else if (thumbnail) { return thumbnail; } else { return url; } } else if (thumbnail) { return thumbnail; } else { return undefined; } } thumbnail() { const post = this.postView.post; const url = post.url; const thumbnail = post.thumbnail_url; if (!this.props.hideImage && url && isImage(url) && this.imageSrc) { return ( ); } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) { return ( {this.imgThumb(this.imageSrc)} ); } else if (url) { if ((!this.props.hideImage && isVideo(url)) || post.embed_video_url) { return (
); } else { return (
); } } else { return (
); } } createdLine() { const post_view = this.postView; return (
{this.creatorIsMod_ && getRoleLabelPill({ label: I18NextService.i18n.t("mod"), tooltip: I18NextService.i18n.t("mod"), classes: "text-bg-primary", })} {this.creatorIsAdmin_ && getRoleLabelPill({ label: I18NextService.i18n.t("admin"), tooltip: I18NextService.i18n.t("admin"), classes: "text-bg-danger", })} {post_view.creator.bot_account && getRoleLabelPill({ label: I18NextService.i18n.t("bot_account").toLowerCase(), tooltip: I18NextService.i18n.t("bot_account"), })} {this.props.showCommunity && ( <> {" "} {I18NextService.i18n.t("to")}{" "} )} {post_view.post.language_id !== 0 && ( { this.props.allLanguages.find( lang => lang.id === post_view.post.language_id )?.name } )}{" "} •{" "}
); } get postLink() { const post = this.postView.post; return ( ); } postTitleLine() { const post = this.postView.post; const url = post.url; return ( <>
{url && this.props.showBody ? ( ) : ( this.postLink )}
{/** * If there is a URL, an embed title, and we were not told to show the * body by the parent component, show the MetadataCard/body toggle. */} {!this.props.showBody && post.url && post.embed_title && this.showPreviewButton()} {post.removed && ( {I18NextService.i18n.t("removed")} )} {post.deleted && ( )} {post.locked && ( )} {post.featured_community && ( )} {post.featured_local && ( )} {post.nsfw && ( {I18NextService.i18n.t("nsfw")} )}
{url && this.urlLine()} ); } urlLine() { const post = this.postView.post; const url = post.url; return (

{url && !(hostname(url) === getExternalHost()) && ( {hostname(url)} )}

); } duplicatesLine() { const dupes = this.props.crossPosts; return dupes && dupes.length > 0 ? (
    <>
  • {I18NextService.i18n.t("cross_posted_to")}
  • {dupes.map(pv => (
  • {pv.community.local ? pv.community.name : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
  • ))}
) : ( <> ); } commentsLine(mobile = false) { const post = this.postView.post; return (
{this.commentsButton} {canShare() && ( )} {!post.local && ( )} {mobile && !this.props.viewOnly && ( )} {UserService.Instance.myUserInfo && !this.props.viewOnly && this.postActions()}
); } postActions() { // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button. // Possible enhancement: Make each button a component. const post_view = this.postView; const post = post_view.post; return ( <> {this.saveButton} {this.crossPostButton} {this.props.showBody && post_view.post.body && this.viewSourceButton}
    {!this.myPost ? ( <>
  • {this.reportButton}
  • {this.blockButton}
  • ) : ( <>
  • {this.editButton}
  • {this.deleteButton}
  • )} {/* Any mod can do these, not limited to hierarchy*/} {(amMod(this.props.moderators) || amAdmin()) && ( <>

  • {this.lockButton}
  • {this.featureButtons} )} {(this.canMod_ || this.canAdmin_) && (
  • {this.modRemoveButton}
  • )}
); } get commentsButton() { const post_view = this.postView; const title = I18NextService.i18n.t("number_of_comments", { count: Number(post_view.counts.comments), formattedCount: Number(post_view.counts.comments), }); return ( {post_view.counts.comments} {this.unreadCount && ( <> {" "} ({this.unreadCount} {I18NextService.i18n.t("new")}) )} ); } get unreadCount(): number | undefined { const pv = this.postView; return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0 ? undefined : pv.unread_comments; } get saveButton() { const saved = this.postView.saved; const label = saved ? I18NextService.i18n.t("unsave") : I18NextService.i18n.t("save"); return ( ); } get crossPostButton() { return ( ); } get reportButton() { return ( ); } get blockButton() { return ( ); } get editButton() { return ( ); } get deleteButton() { const deleted = this.postView.post.deleted; const label = !deleted ? I18NextService.i18n.t("delete") : I18NextService.i18n.t("restore"); return ( ); } get viewSourceButton() { return ( ); } get lockButton() { const locked = this.postView.post.locked; const label = locked ? I18NextService.i18n.t("unlock") : I18NextService.i18n.t("lock"); return ( ); } get featureButtons() { const featuredCommunity = this.postView.post.featured_community; const labelCommunity = featuredCommunity ? I18NextService.i18n.t("unfeature_from_community") : I18NextService.i18n.t("feature_in_community"); const featuredLocal = this.postView.post.featured_local; const labelLocal = featuredLocal ? I18NextService.i18n.t("unfeature_from_local") : I18NextService.i18n.t("feature_in_local"); return ( <>
  • {amAdmin() && ( )}
  • ); } get modBanFromCommunityButton() { return ( ); } get modUnbanFromCommunityButton() { return ( ); } get addModToCommunityButton() { return ( ); } get modBanButton() { return ( ); } get modUnbanButton() { return ( ); } get purgePersonButton() { return ( ); } get purgePostButton() { return ( ); } get toggleAdminButton() { return ( ); } get modRemoveButton() { const removed = this.postView.post.removed; return ( ); } /** * Mod/Admin actions to be taken against the author. */ userActionsLine() { // TODO: make nicer const post_view = this.postView; return ( this.state.showAdvanced && (
    {this.canMod_ && ( <> {!this.creatorIsMod_ && (!post_view.creator_banned_from_community ? this.modBanFromCommunityButton : this.modUnbanFromCommunityButton)} {!post_view.creator_banned_from_community && this.addModToCommunityButton} )} {/* Community creators and admins can transfer community to another mod */} {(amCommunityCreator(post_view.creator.id, this.props.moderators) || this.canAdmin_) && this.creatorIsMod_ && (!this.state.showConfirmTransferCommunity ? ( ) : ( <> ))} {/* Admins can ban from all, and appoint other admins */} {this.canAdmin_ && ( <> {!this.creatorIsAdmin_ && ( <> {!isBanned(post_view.creator) ? this.modBanButton : this.modUnbanButton} {this.purgePersonButton} {this.purgePostButton} )} {!isBanned(post_view.creator) && post_view.creator.local && this.toggleAdminButton} )}
    ) ); } removeAndBanDialogs() { const post = this.postView; const purgeTypeText = this.state.purgeType == PurgeType.Post ? I18NextService.i18n.t("purge_post") : `${I18NextService.i18n.t("purge")} ${post.creator.name}`; return ( <> {this.state.showRemoveDialog && (
    )} {this.state.showBanDialog && (
    {/* TODO hold off on expires until later */} {/*
    */} {/* */} {/* */} {/*
    */}
    )} {this.state.showReportDialog && (
    )} {this.state.showPurgeDialog && (
    {this.state.purgeLoading ? ( ) : ( )} )} ); } mobileThumbnail() { const post = this.postView.post; return post.thumbnail_url || (post.url && isImage(post.url)) ? (
    {this.postTitleLine()}
    {/* Post thumbnail */} {!this.state.imageExpanded && this.thumbnail()}
    ) : ( this.postTitleLine() ); } showPreviewButton() { return ( ); } listing() { return ( <> {/* The mobile view*/}
    {this.createdLine()} {/* If it has a thumbnail, do a right aligned thumbnail */} {this.mobileThumbnail()} {this.commentsLine(true)} {this.userActionsLine()} {this.duplicatesLine()} {this.removeAndBanDialogs()}
    {/* The larger view*/}
    {!this.props.viewOnly && (
    )}
    {this.thumbnail()}
    {this.postTitleLine()} {this.createdLine()} {this.commentsLine()} {this.duplicatesLine()} {this.userActionsLine()} {this.removeAndBanDialogs()}
    ); } private get myPost(): boolean { return ( this.postView.creator.id == UserService.Instance.myUserInfo?.local_user_view.person.id ); } handleEditClick(i: PostListing) { i.setState({ showEdit: true }); } handleEditCancel() { this.setState({ showEdit: false }); } // The actual editing is done in the receive for post handleEditPost(form: EditPost) { this.setState({ showEdit: false }); this.props.onPostEdit(form); } handleShare(i: PostListing) { const { name, body, id } = i.props.post_view.post; share({ title: name, text: body?.slice(0, 50), url: `${getHttpBase()}/post/${id}`, }); } handleShowReportDialog(i: PostListing) { i.setState({ showReportDialog: !i.state.showReportDialog }); } handleReportReasonChange(i: PostListing, event: any) { i.setState({ reportReason: event.target.value }); } handleReportSubmit(i: PostListing, event: any) { event.preventDefault(); i.setState({ reportLoading: true }); i.props.onPostReport({ post_id: i.postView.post.id, reason: i.state.reportReason ?? "", auth: myAuthRequired(), }); } handleBlockPersonClick(i: PostListing) { i.setState({ blockLoading: true }); i.props.onBlockPerson({ person_id: i.postView.creator.id, block: true, auth: myAuthRequired(), }); } handleDeleteClick(i: PostListing) { i.setState({ deleteLoading: true }); i.props.onDeletePost({ post_id: i.postView.post.id, deleted: !i.postView.post.deleted, auth: myAuthRequired(), }); } handleSavePostClick(i: PostListing) { i.setState({ saveLoading: true }); i.props.onSavePost({ post_id: i.postView.post.id, save: !i.postView.saved, auth: myAuthRequired(), }); } get crossPostParams(): PostFormParams { const queryParams: PostFormParams = {}; const { name, url } = this.postView.post; queryParams.name = name; if (url) { queryParams.url = url; } const crossPostBody = this.crossPostBody(); if (crossPostBody) { queryParams.body = crossPostBody; } return queryParams; } crossPostBody(): string | undefined { const post = this.postView.post; const body = post.body; return body ? `${I18NextService.i18n.t("cross_posted_from")} ${ post.ap_id }\n\n${body.replace(/^/gm, "> ")}` : undefined; } get showBody(): boolean { return this.props.showBody || this.state.showBody; } handleModRemoveShow(i: PostListing) { i.setState({ showRemoveDialog: !i.state.showRemoveDialog, showBanDialog: false, }); } handleModRemoveReasonChange(i: PostListing, event: any) { i.setState({ removeReason: event.target.value }); } handleModRemoveDataChange(i: PostListing, event: any) { i.setState({ removeData: event.target.checked }); } handleModRemoveSubmit(i: PostListing, event: any) { event.preventDefault(); i.setState({ removeLoading: true }); i.props.onRemovePost({ post_id: i.postView.post.id, removed: !i.postView.post.removed, auth: myAuthRequired(), }); } handleModLock(i: PostListing) { i.setState({ lockLoading: true }); i.props.onLockPost({ post_id: i.postView.post.id, locked: !i.postView.post.locked, auth: myAuthRequired(), }); } handleModFeaturePostLocal(i: PostListing) { i.setState({ featureLocalLoading: true }); i.props.onFeaturePost({ post_id: i.postView.post.id, featured: !i.postView.post.featured_local, feature_type: "Local", auth: myAuthRequired(), }); } handleModFeaturePostCommunity(i: PostListing) { i.setState({ featureCommunityLoading: true }); i.props.onFeaturePost({ post_id: i.postView.post.id, featured: !i.postView.post.featured_community, feature_type: "Community", auth: myAuthRequired(), }); } handleModBanFromCommunityShow(i: PostListing) { i.setState({ showBanDialog: true, banType: BanType.Community, showRemoveDialog: false, }); } handleModBanShow(i: PostListing) { i.setState({ showBanDialog: true, banType: BanType.Site, showRemoveDialog: false, }); } handlePurgePersonShow(i: PostListing) { i.setState({ showPurgeDialog: true, purgeType: PurgeType.Person, showRemoveDialog: false, }); } handlePurgePostShow(i: PostListing) { i.setState({ showPurgeDialog: true, purgeType: PurgeType.Post, showRemoveDialog: false, }); } handlePurgeReasonChange(i: PostListing, event: any) { i.setState({ purgeReason: event.target.value }); } handlePurgeSubmit(i: PostListing, event: any) { event.preventDefault(); i.setState({ purgeLoading: true }); if (i.state.purgeType == PurgeType.Person) { i.props.onPurgePerson({ person_id: i.postView.creator.id, reason: i.state.purgeReason, auth: myAuthRequired(), }); } else if (i.state.purgeType == PurgeType.Post) { i.props.onPurgePost({ post_id: i.postView.post.id, reason: i.state.purgeReason, auth: myAuthRequired(), }); } } handleModBanReasonChange(i: PostListing, event: any) { i.setState({ banReason: event.target.value }); } handleModBanExpireDaysChange(i: PostListing, event: any) { i.setState({ banExpireDays: event.target.value }); } handleModBanFromCommunitySubmit(i: PostListing, event: any) { i.setState({ banType: BanType.Community }); i.handleModBanBothSubmit(i, event); } handleModBanSubmit(i: PostListing, event: any) { i.setState({ banType: BanType.Site }); i.handleModBanBothSubmit(i, event); } handleModBanBothSubmit(i: PostListing, event: any) { event.preventDefault(); i.setState({ banLoading: true }); const ban = !i.props.post_view.creator_banned_from_community; // If its an unban, restore all their data if (ban == false) { i.setState({ removeData: false }); } const person_id = i.props.post_view.creator.id; const remove_data = i.state.removeData; const reason = i.state.banReason; const expires = futureDaysToUnixTime(i.state.banExpireDays); if (i.state.banType == BanType.Community) { const community_id = i.postView.community.id; i.props.onBanPersonFromCommunity({ community_id, person_id, ban, remove_data, reason, expires, auth: myAuthRequired(), }); } else { i.props.onBanPerson({ person_id, ban, remove_data, reason, expires, auth: myAuthRequired(), }); } } handleAddModToCommunity(i: PostListing) { i.setState({ addModLoading: true }); i.props.onAddModToCommunity({ community_id: i.postView.community.id, person_id: i.postView.creator.id, added: !i.creatorIsMod_, auth: myAuthRequired(), }); } handleAddAdmin(i: PostListing) { i.setState({ addAdminLoading: true }); i.props.onAddAdmin({ person_id: i.postView.creator.id, added: !i.creatorIsAdmin_, auth: myAuthRequired(), }); } handleShowConfirmTransferCommunity(i: PostListing) { i.setState({ showConfirmTransferCommunity: true }); } handleCancelShowConfirmTransferCommunity(i: PostListing) { i.setState({ showConfirmTransferCommunity: false }); } handleTransferCommunity(i: PostListing) { i.setState({ transferLoading: true }); i.props.onTransferCommunity({ community_id: i.postView.community.id, person_id: i.postView.creator.id, auth: myAuthRequired(), }); } handleShowConfirmTransferSite(i: PostListing) { i.setState({ showConfirmTransferSite: true }); } handleCancelShowConfirmTransferSite(i: PostListing) { i.setState({ showConfirmTransferSite: false }); } handleImageExpandClick(i: PostListing, event: any) { event.preventDefault(); i.setState({ imageExpanded: !i.state.imageExpanded }); setupTippy(); } handleViewSource(i: PostListing) { i.setState({ viewSource: !i.state.viewSource }); } handleShowAdvanced(i: PostListing) { i.setState({ showAdvanced: !i.state.showAdvanced }); setupTippy(); } handleShowMoreMobile(i: PostListing) { i.setState({ showMoreMobile: !i.state.showMoreMobile, showAdvanced: !i.state.showAdvanced, }); setupTippy(); } handleShowBody(i: PostListing) { i.setState({ showBody: !i.state.showBody }); setupTippy(); } get pointsTippy(): string { const points = I18NextService.i18n.t("number_of_points", { count: Number(this.postView.counts.score), formattedCount: Number(this.postView.counts.score), }); const upvotes = I18NextService.i18n.t("number_of_upvotes", { count: Number(this.postView.counts.upvotes), formattedCount: Number(this.postView.counts.upvotes), }); const downvotes = I18NextService.i18n.t("number_of_downvotes", { count: Number(this.postView.counts.downvotes), formattedCount: Number(this.postView.counts.downvotes), }); return `${points} • ${upvotes} • ${downvotes}`; } get canModOnSelf_(): boolean { return canMod( this.postView.creator.id, this.props.moderators, this.props.admins, undefined, true ); } get canMod_(): boolean { return canMod( this.postView.creator.id, this.props.moderators, this.props.admins ); } get canAdmin_(): boolean { return canAdmin(this.postView.creator.id, this.props.admins); } get creatorIsMod_(): boolean { return isMod(this.postView.creator.id, this.props.moderators); } get creatorIsAdmin_(): boolean { return isAdmin(this.postView.creator.id, this.props.admins); } }