import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { AddAdmin, AddModToCommunity, BanFromCommunity, BanPerson, BlockPerson, CommunityModeratorView, CreatePostLike, CreatePostReport, DeletePost, FeaturePost, Language, LockPost, PersonView, PostView, PurgePerson, PurgePost, RemovePost, SavePost, TransferCommunity, } from "lemmy-js-client"; import { getExternalHost, getHttpBase } from "../../env"; import { i18n } from "../../i18next"; import { BanType, PostFormParams, PurgeType } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { amAdmin, amCommunityCreator, amMod, canAdmin, canMod, canShare, futureDaysToUnixTime, hostname, isAdmin, isBanned, isImage, isMod, isVideo, mdNoImages, mdToHtml, mdToHtmlInline, myAuth, numToSI, relTags, setupTippy, share, showScores, wsClient, } from "../../utils"; import { Icon, PurgeWarning, Spinner } from "../common/icon"; import { MomentTime } from "../common/moment-time"; import { PictrsImage } from "../common/pictrs-image"; 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; my_vote?: number; score: number; upvotes: number; downvotes: number; } interface PostListingProps { post_view: PostView; duplicates?: PostView[]; moderators?: CommunityModeratorView[]; admins?: PersonView[]; allLanguages: Language[]; siteLanguages: number[]; showCommunity?: boolean; showBody?: boolean; hideImage?: boolean; enableDownvotes?: boolean; enableNsfw?: boolean; viewOnly?: boolean; } export class PostListing extends Component { state: PostListingState = { showEdit: false, showRemoveDialog: false, showPurgeDialog: false, purgeType: PurgeType.Person, purgeLoading: false, showBanDialog: false, banType: BanType.Community, removeData: false, showConfirmTransferSite: false, showConfirmTransferCommunity: false, imageExpanded: false, viewSource: false, showAdvanced: false, showMoreMobile: false, showBody: false, showReportDialog: false, my_vote: this.props.post_view.my_vote, score: this.props.post_view.counts.score, upvotes: this.props.post_view.counts.upvotes, downvotes: this.props.post_view.counts.downvotes, }; constructor(props: any, context: any) { super(props, context); this.handlePostLike = this.handlePostLike.bind(this); this.handlePostDisLike = this.handlePostDisLike.bind(this); this.handleEditPost = this.handleEditPost.bind(this); this.handleEditCancel = this.handleEditCancel.bind(this); } componentWillReceiveProps(nextProps: PostListingProps) { this.setState({ my_vote: nextProps.post_view.my_vote, upvotes: nextProps.post_view.counts.upvotes, downvotes: nextProps.post_view.counts.downvotes, score: nextProps.post_view.counts.score, }); if (this.props.post_view.post.id !== nextProps.post_view.post.id) { this.setState({ imageExpanded: false }); } } render() { const post = this.props.post_view.post; return (
{!this.state.showEdit ? ( <> {this.listing()} {this.state.imageExpanded && !this.props.hideImage && this.img} {post.url && this.showBody && post.embed_title && ( )} {this.showBody && this.body()} ) : (
)}
); } body() { let body = this.props.post_view.post.body; return body ? (
{this.state.viewSource ? (
{body}
) : (
)}
) : ( <> ); } get img() { let src = this.imageSrc; return src ? ( <>
) : ( <> ); } imgThumb(src: string) { let post_view = this.props.post_view; return ( ); } get imageSrc(): string | undefined { let post = this.props.post_view.post; let url = post.url; let 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() { let post = this.props.post_view.post; let url = post.url; let thumbnail = post.thumbnail_url; if (!this.props.hideImage && url && isImage(url) && this.imageSrc) { return ( {this.imgThumb(this.imageSrc)} ); } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) { return ( {this.imgThumb(this.imageSrc)} ); } else if (url) { if (!this.props.hideImage && isVideo(url)) { return (
); } else { return (
); } } else { return (
); } } createdLine() { let post_view = this.props.post_view; let url = post_view.post.url; let body = post_view.post.body; return (
  • {this.creatorIsMod_ && ( {i18n.t("mod")} )} {this.creatorIsAdmin_ && ( {i18n.t("admin")} )} {post_view.creator.bot_account && ( {i18n.t("bot_account").toLowerCase()} )} {this.props.showCommunity && ( {i18n.t("to")} )}
  • { this.props.allLanguages.find( lang => lang.id === post_view.post.language_id )?.name }
  • {url && !(hostname(url) === getExternalHost()) && ( <>
  • {hostname(url)}
  • )}
  • {body && ( <>
  • )}
); } voteBar() { return (
{showScores() ? (
{numToSI(this.state.score)}
) : (
)} {this.props.enableDownvotes && ( )}
); } get postLink() { let post = this.props.post_view.post; return (
); } postTitleLine() { let post = this.props.post_view.post; let url = post.url; return (
{url ? ( this.props.showBody ? (
) : ( this.postLink ) ) : ( this.postLink )} {(url && isImage(url)) || (post.thumbnail_url && ( ))} {post.removed && ( {i18n.t("removed")} )} {post.deleted && ( )} {post.locked && ( )} {post.featured_community && ( )} {post.featured_local && ( )} {post.nsfw && ( {i18n.t("nsfw")} )}
); } duplicatesLine() { let dupes = this.props.duplicates; return dupes && dupes.length > 0 ? (
    <>
  • {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) { let post = this.props.post_view.post; return (
{this.commentsButton} {canShare() && ( )} {!post.local && ( )} {mobile && !this.props.viewOnly && this.mobileVotes} {UserService.Instance.myUserInfo && !this.props.viewOnly && this.postActions(mobile)}
); } postActions(mobile = false) { // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button. // Possible enhancement: Make each button a component. let post_view = this.props.post_view; return ( <> {this.saveButton} {this.crossPostButton} {mobile && this.showMoreButton} {(!mobile || this.state.showAdvanced) && ( <> {!this.myPost && ( <> {this.reportButton} {this.blockButton} )} {this.myPost && (this.showBody || this.state.showAdvanced) && ( <> {this.editButton} {this.deleteButton} )} )} {this.state.showAdvanced && ( <> {this.showBody && post_view.post.body && this.viewSourceButton} {/* Any mod can do these, not limited to hierarchy*/} {(amMod(this.props.moderators) || amAdmin()) && ( <> {this.lockButton} {this.featureButton} )} {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}} )} {!mobile && this.showMoreButton} ); } get commentsButton() { let post_view = this.props.post_view; return ( ); } get unreadCount(): number | undefined { let pv = this.props.post_view; return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0 ? undefined : pv.unread_comments; } get mobileVotes() { // TODO: make nicer let tippy = showScores() ? { "data-tippy-content": this.pointsTippy } : {}; return ( <>
{this.props.enableDownvotes && ( )}
); } get saveButton() { let saved = this.props.post_view.saved; let label = saved ? i18n.t("unsave") : i18n.t("save"); return ( ); } get crossPostButton() { return ( ); } get reportButton() { return ( ); } get blockButton() { return ( ); } get editButton() { return ( ); } get deleteButton() { let deleted = this.props.post_view.post.deleted; let label = !deleted ? i18n.t("delete") : i18n.t("restore"); return ( ); } get showMoreButton() { return ( ); } get viewSourceButton() { return ( ); } get lockButton() { let locked = this.props.post_view.post.locked; let label = locked ? i18n.t("unlock") : i18n.t("lock"); return ( ); } get featureButton() { const featuredCommunity = this.props.post_view.post.featured_community; const labelCommunity = featuredCommunity ? i18n.t("unfeature_from_community") : i18n.t("feature_in_community"); const featuredLocal = this.props.post_view.post.featured_local; const labelLocal = featuredLocal ? i18n.t("unfeature_from_local") : i18n.t("feature_in_local"); return ( {amAdmin() && ( )} ); } get modRemoveButton() { let removed = this.props.post_view.post.removed; return ( ); } /** * Mod/Admin actions to be taken against the author. */ userActionsLine() { // TODO: make nicer let post_view = this.props.post_view; return ( this.state.showAdvanced && ( <> {this.canMod_ && ( <> {!this.creatorIsMod_ && (!post_view.creator_banned_from_community ? ( ) : ( ))} {!post_view.creator_banned_from_community && ( )} )} {/* 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) ? ( ) : ( )} )} {!isBanned(post_view.creator) && post_view.creator.local && ( )} )} ) ); } removeAndBanDialogs() { let post = this.props.post_view; let purgeTypeText = this.state.purgeType == PurgeType.Post ? i18n.t("purge_post") : `${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() { let post = this.props.post_view.post; return post.thumbnail_url || (post.url && isImage(post.url)) ? (
{this.postTitleLine()}
{/* Post body prev or thumbnail */} {!this.state.imageExpanded && this.thumbnail()}
) : ( this.postTitleLine() ); } showMobilePreview() { let body = this.props.post_view.post.body; return !this.showBody && body ? (
{body}
) : ( <> ); } listing() { return ( <> {/* The mobile view*/}
{this.createdLine()} {/* If it has a thumbnail, do a right aligned thumbnail */} {this.mobileThumbnail()} {/* Show a preview of the post body */} {this.showMobilePreview()} {this.commentsLine(true)} {this.userActionsLine()} {this.duplicatesLine()} {this.removeAndBanDialogs()}
{/* The larger view*/}
{!this.props.viewOnly && this.voteBar()}
{this.thumbnail()}
{this.postTitleLine()} {this.createdLine()} {this.commentsLine()} {this.duplicatesLine()} {this.userActionsLine()} {this.removeAndBanDialogs()}
); } private get myPost(): boolean { return ( this.props.post_view.creator.id == UserService.Instance.myUserInfo?.local_user_view.person.id ); } handlePostLike(event: any) { event.preventDefault(); if (!UserService.Instance.myUserInfo) { this.context.router.history.push(`/login`); } let myVote = this.state.my_vote; let newVote = myVote == 1 ? 0 : 1; if (myVote == 1) { this.setState({ score: this.state.score - 1, upvotes: this.state.upvotes - 1, }); } else if (myVote == -1) { this.setState({ score: this.state.score + 2, upvotes: this.state.upvotes + 1, downvotes: this.state.downvotes - 1, }); } else { this.setState({ score: this.state.score + 1, upvotes: this.state.upvotes + 1, }); } this.setState({ my_vote: newVote }); let auth = myAuth(); if (auth) { let form: CreatePostLike = { post_id: this.props.post_view.post.id, score: newVote, auth, }; WebSocketService.Instance.send(wsClient.likePost(form)); this.setState(this.state); } setupTippy(); } handlePostDisLike(event: any) { event.preventDefault(); if (!UserService.Instance.myUserInfo) { this.context.router.history.push(`/login`); } let myVote = this.state.my_vote; let newVote = myVote == -1 ? 0 : -1; if (myVote == 1) { this.setState({ score: this.state.score - 2, upvotes: this.state.upvotes - 1, downvotes: this.state.downvotes + 1, }); } else if (myVote == -1) { this.setState({ score: this.state.score + 1, downvotes: this.state.downvotes - 1, }); } else { this.setState({ score: this.state.score - 1, downvotes: this.state.downvotes + 1, }); } this.setState({ my_vote: newVote }); let auth = myAuth(); if (auth) { let form: CreatePostLike = { post_id: this.props.post_view.post.id, score: newVote, auth, }; WebSocketService.Instance.send(wsClient.likePost(form)); this.setState(this.state); } setupTippy(); } handleEditClick(i: PostListing) { i.setState({ showEdit: true }); } handleEditCancel() { this.setState({ showEdit: false }); } // The actual editing is done in the recieve for post handleEditPost() { this.setState({ showEdit: false }); } 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(); let auth = myAuth(); let reason = i.state.reportReason; if (auth && reason) { let form: CreatePostReport = { post_id: i.props.post_view.post.id, reason, auth, }; WebSocketService.Instance.send(wsClient.createPostReport(form)); i.setState({ showReportDialog: false }); } } handleBlockUserClick(i: PostListing) { let auth = myAuth(); if (auth) { let blockUserForm: BlockPerson = { person_id: i.props.post_view.creator.id, block: true, auth, }; WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } } handleDeleteClick(i: PostListing) { let auth = myAuth(); if (auth) { let deleteForm: DeletePost = { post_id: i.props.post_view.post.id, deleted: !i.props.post_view.post.deleted, auth, }; WebSocketService.Instance.send(wsClient.deletePost(deleteForm)); } } handleSavePostClick(i: PostListing) { let auth = myAuth(); if (auth) { let saved = i.props.post_view.saved == undefined ? true : !i.props.post_view.saved; let form: SavePost = { post_id: i.props.post_view.post.id, save: saved, auth, }; WebSocketService.Instance.send(wsClient.savePost(form)); } } get crossPostParams(): PostFormParams { const queryParams: PostFormParams = {}; const { name, url } = this.props.post_view.post; queryParams.name = name; if (url) { queryParams.url = url; } const crossPostBody = this.crossPostBody(); if (crossPostBody) { queryParams.body = crossPostBody; } return queryParams; } crossPostBody(): string | undefined { let post = this.props.post_view.post; let body = post.body; return body ? `${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(); let auth = myAuth(); if (auth) { let form: RemovePost = { post_id: i.props.post_view.post.id, removed: !i.props.post_view.post.removed, reason: i.state.removeReason, auth, }; WebSocketService.Instance.send(wsClient.removePost(form)); i.setState({ showRemoveDialog: false }); } } handleModLock(i: PostListing) { let auth = myAuth(); if (auth) { let form: LockPost = { post_id: i.props.post_view.post.id, locked: !i.props.post_view.post.locked, auth, }; WebSocketService.Instance.send(wsClient.lockPost(form)); } } handleModFeaturePostLocal(i: PostListing) { let auth = myAuth(); if (auth) { let form: FeaturePost = { post_id: i.props.post_view.post.id, feature_type: "Local", featured: !i.props.post_view.post.featured_local, auth, }; WebSocketService.Instance.send(wsClient.featurePost(form)); } } handleModFeaturePostCommunity(i: PostListing) { let auth = myAuth(); if (auth) { let form: FeaturePost = { post_id: i.props.post_view.post.id, feature_type: "Community", featured: !i.props.post_view.post.featured_community, auth, }; WebSocketService.Instance.send(wsClient.featurePost(form)); } } 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(); let auth = myAuth(); if (auth) { if (i.state.purgeType == PurgeType.Person) { let form: PurgePerson = { person_id: i.props.post_view.creator.id, reason: i.state.purgeReason, auth, }; WebSocketService.Instance.send(wsClient.purgePerson(form)); } else if (i.state.purgeType == PurgeType.Post) { let form: PurgePost = { post_id: i.props.post_view.post.id, reason: i.state.purgeReason, auth, }; WebSocketService.Instance.send(wsClient.purgePost(form)); } i.setState({ purgeLoading: true }); } } 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) { i.setState({ banType: BanType.Community }); i.handleModBanBothSubmit(i); } handleModBanSubmit(i: PostListing) { i.setState({ banType: BanType.Site }); i.handleModBanBothSubmit(i); } handleModBanBothSubmit(i: PostListing, event?: any) { if (event) event.preventDefault(); let auth = myAuth(); if (auth) { let ban = !i.props.post_view.creator_banned_from_community; let person_id = i.props.post_view.creator.id; let remove_data = i.state.removeData; let reason = i.state.banReason; let expires = futureDaysToUnixTime(i.state.banExpireDays); if (i.state.banType == BanType.Community) { // If its an unban, restore all their data if (ban == false) { i.setState({ removeData: false }); } let form: BanFromCommunity = { person_id, community_id: i.props.post_view.community.id, ban, remove_data, reason, expires, auth, }; WebSocketService.Instance.send(wsClient.banFromCommunity(form)); } else { // If its an unban, restore all their data let ban = !i.props.post_view.creator.banned; if (ban == false) { i.setState({ removeData: false }); } let form: BanPerson = { person_id, ban, remove_data, reason, expires, auth, }; WebSocketService.Instance.send(wsClient.banPerson(form)); } i.setState({ showBanDialog: false }); } } handleAddModToCommunity(i: PostListing) { let auth = myAuth(); if (auth) { let form: AddModToCommunity = { person_id: i.props.post_view.creator.id, community_id: i.props.post_view.community.id, added: !i.creatorIsMod_, auth, }; WebSocketService.Instance.send(wsClient.addModToCommunity(form)); i.setState(i.state); } } handleAddAdmin(i: PostListing) { let auth = myAuth(); if (auth) { let form: AddAdmin = { person_id: i.props.post_view.creator.id, added: !i.creatorIsAdmin_, auth, }; WebSocketService.Instance.send(wsClient.addAdmin(form)); i.setState(i.state); } } handleShowConfirmTransferCommunity(i: PostListing) { i.setState({ showConfirmTransferCommunity: true }); } handleCancelShowConfirmTransferCommunity(i: PostListing) { i.setState({ showConfirmTransferCommunity: false }); } handleTransferCommunity(i: PostListing) { let auth = myAuth(); if (auth) { let form: TransferCommunity = { community_id: i.props.post_view.community.id, person_id: i.props.post_view.creator.id, auth, }; WebSocketService.Instance.send(wsClient.transferCommunity(form)); i.setState({ showConfirmTransferCommunity: false }); } } 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 { let points = i18n.t("number_of_points", { count: Number(this.state.score), formattedCount: Number(this.state.score), }); let upvotes = i18n.t("number_of_upvotes", { count: Number(this.state.upvotes), formattedCount: Number(this.state.upvotes), }); let downvotes = i18n.t("number_of_downvotes", { count: Number(this.state.downvotes), formattedCount: Number(this.state.downvotes), }); return `${points} • ${upvotes} • ${downvotes}`; } get canModOnSelf_(): boolean { return canMod( this.props.post_view.creator.id, this.props.moderators, this.props.admins, undefined, true ); } get canMod_(): boolean { return canMod( this.props.post_view.creator.id, this.props.moderators, this.props.admins ); } get canAdmin_(): boolean { return canAdmin(this.props.post_view.creator.id, this.props.admins); } get creatorIsMod_(): boolean { return isMod(this.props.post_view.creator.id, this.props.moderators); } get creatorIsAdmin_(): boolean { return isAdmin(this.props.post_view.creator.id, this.props.admins); } }