import { None, Option, Some } from "@sniptt/monads"; import classNames from "classnames"; import { Component, linkEvent } from "inferno"; import { Link } from "inferno-router"; import { AddAdmin, AddModToCommunity, BanFromCommunity, BanPerson, BlockPerson, CommunityModeratorView, CreatePostLike, CreatePostReport, DeletePost, Language, LockPost, PersonViewSafe, PostView, PurgePerson, PurgePost, RemovePost, SavePost, StickyPost, toUndefined, TransferCommunity, } from "lemmy-js-client"; import { externalHost } from "../../env"; import { i18n } from "../../i18next"; import { BanType, PurgeType } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { amCommunityCreator, auth, canAdmin, canMod, futureDaysToUnixTime, hostname, isAdmin, isBanned, isImage, isMod, isVideo, md, mdToHtml, mdToHtmlInline, numToSI, relTags, setupTippy, 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: Option; purgeType: PurgeType; purgeLoading: boolean; removeReason: Option; showBanDialog: boolean; banReason: Option; banExpireDays: Option; banType: BanType; removeData: boolean; showConfirmTransferSite: boolean; showConfirmTransferCommunity: boolean; imageExpanded: boolean; viewSource: boolean; showAdvanced: boolean; showMoreMobile: boolean; showBody: boolean; showReportDialog: boolean; reportReason: Option; my_vote: Option; score: number; upvotes: number; downvotes: number; } interface PostListingProps { post_view: PostView; duplicates: Option; moderators: Option; admins: Option; allLanguages: Language[]; showCommunity?: boolean; showBody?: boolean; enableDownvotes?: boolean; enableNsfw?: boolean; viewOnly?: boolean; } export class PostListing extends Component { private emptyState: PostListingState = { showEdit: false, showRemoveDialog: false, showPurgeDialog: false, purgeReason: None, purgeType: PurgeType.Person, purgeLoading: false, removeReason: None, showBanDialog: false, banReason: None, banExpireDays: None, banType: BanType.Community, removeData: false, showConfirmTransferSite: false, showConfirmTransferCommunity: false, imageExpanded: false, viewSource: false, showAdvanced: false, showMoreMobile: false, showBody: false, showReportDialog: false, reportReason: None, 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.state = this.emptyState; 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() { let post = this.props.post_view.post; return (
{!this.state.showEdit ? ( <> {this.listing()} {this.state.imageExpanded && this.img} {post.url.isSome() && this.showBody && post.embed_title.isSome() && } {this.showBody && this.body()} ) : (
)}
); } body() { return this.props.post_view.post.body.match({ some: body => (
{this.state.viewSource ? (
{body}
) : (
)}
), none: <>, }); } get img() { return this.imageSrc.match({ some: src => ( <> ), none: <>, }); } imgThumb(src: string) { let post_view = this.props.post_view; return ( ); } get imageSrc(): Option { let post = this.props.post_view.post; let url = post.url; let thumbnail = post.thumbnail_url; if (url.isSome() && isImage(url.unwrap())) { if (url.unwrap().includes("pictrs")) { return url; } else if (thumbnail.isSome()) { return thumbnail; } else { return url; } } else if (thumbnail.isSome()) { return thumbnail; } else { return None; } } thumbnail() { let post = this.props.post_view.post; let url = post.url; let thumbnail = post.thumbnail_url; if (url.isSome() && isImage(url.unwrap())) { return ( {this.imgThumb(this.imageSrc.unwrap())} ); } else if (url.isSome() && thumbnail.isSome()) { return ( {this.imgThumb(this.imageSrc.unwrap())} ); } else if (url.isSome()) { if (isVideo(url.unwrap())) { return (
); } else { return (
); } } else { return (
); } } createdLine() { let post_view = this.props.post_view; return (
  • {this.creatorIsMod_ && ( {i18n.t("mod")} )} {this.creatorIsAdmin_ && ( {i18n.t("admin")} )} {post_view.creator.bot_account && ( {i18n.t("bot_account").toLowerCase()} )} {(post_view.creator_banned_from_community || isBanned(post_view.creator)) && ( {i18n.t("banned")} )} {post_view.creator_blocked && ( {"blocked"} )} {this.props.showCommunity && ( {i18n.t("to")} )}
  • {post_view.post.url.match({ some: url => !(hostname(url) == externalHost) && ( <>
  • {hostname(url)}
  • ), none: <>, })}
  • {post_view.post.body.match({ some: body => ( <>
  • ), none: <>, })}
); } voteBar() { return (
{showScores() ? (
{numToSI(this.state.score)}
) : (
)} {this.props.enableDownvotes && ( )}
); } postTitleLine() { let post = this.props.post_view.post; return (
{post.url.match({ some: url => (
), none: (
), })} {post.url.map(isImage).or(post.thumbnail_url).unwrapOr(false) && ( )} {post.removed && ( {i18n.t("removed")} )} {post.deleted && ( )} {post.locked && ( )} {post.stickied && ( )} {post.nsfw && ( {i18n.t("nsfw")} )}
); } duplicatesLine() { return this.props.duplicates.match({ some: 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 )}`}
  • ))}
), none: <>, }); } commentsLine(mobile = false) { let post = this.props.post_view.post; return (
{this.commentsButton} {!post.local && ( )} {mobile && !this.props.viewOnly && this.mobileVotes} {UserService.Instance.myUserInfo.isSome() && !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.isSome() && this.viewSourceButton} {this.canModOnSelf_ && ( <> {this.lockButton} {this.stickyButton} )} {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}} )} {!mobile && this.showMoreButton} ); } get commentsButton() { let post_view = this.props.post_view; return ( ); } get unreadCount(): Option { let pv = this.props.post_view; if (pv.unread_comments == pv.counts.comments || pv.unread_comments == 0) { return None; } else { return Some(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 stickyButton() { let stickied = this.props.post_view.post.stickied; let label = stickied ? i18n.t("unsticky") : i18n.t("sticky"); return ( ); } 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(this.props.moderators, post_view.creator.id) || 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: string; if (this.state.purgeType == PurgeType.Post) { purgeTypeText = i18n.t("purge_post"); } else if (this.state.purgeType == PurgeType.Person) { purgeTypeText = `${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.isSome() || post.url.map(isImage).unwrapOr(false) ? (
{this.postTitleLine()}
{/* Post body prev or thumbnail */} {!this.state.imageExpanded && this.thumbnail()}
) : ( this.postTitleLine() ); } showMobilePreview() { let post = this.props.post_view.post; return ( !this.showBody && post.body.match({ some: body =>
{body}
, none: <>, }) ); } 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 UserService.Instance.myUserInfo.match({ some: mui => this.props.post_view.creator.id == mui.local_user_view.person.id, none: false, }); } handlePostLike(event: any) { event.preventDefault(); if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); } let myVote = this.state.my_vote.unwrapOr(0); 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: Some(newVote) }); let form = new CreatePostLike({ post_id: this.props.post_view.post.id, score: newVote, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.likePost(form)); this.setState(this.state); setupTippy(); } handlePostDisLike(event: any) { event.preventDefault(); if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); } let myVote = this.state.my_vote.unwrapOr(0); 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: Some(newVote) }); let form = new CreatePostLike({ post_id: this.props.post_view.post.id, score: newVote, auth: auth().unwrap(), }); 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 }); } handleShowReportDialog(i: PostListing) { i.setState({ showReportDialog: !i.state.showReportDialog }); } handleReportReasonChange(i: PostListing, event: any) { i.setState({ reportReason: Some(event.target.value) }); } handleReportSubmit(i: PostListing, event: any) { event.preventDefault(); let form = new CreatePostReport({ post_id: i.props.post_view.post.id, reason: toUndefined(i.state.reportReason), auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.createPostReport(form)); i.setState({ showReportDialog: false }); } handleBlockUserClick(i: PostListing) { let blockUserForm = new BlockPerson({ person_id: i.props.post_view.creator.id, block: true, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm)); } handleDeleteClick(i: PostListing) { let deleteForm = new DeletePost({ post_id: i.props.post_view.post.id, deleted: !i.props.post_view.post.deleted, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.deletePost(deleteForm)); } handleSavePostClick(i: PostListing) { let saved = i.props.post_view.saved == undefined ? true : !i.props.post_view.saved; let form = new SavePost({ post_id: i.props.post_view.post.id, save: saved, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.savePost(form)); } get crossPostParams(): string { let post = this.props.post_view.post; let params = `?title=${encodeURIComponent(post.name)}`; if (post.url.isSome()) { params += `&url=${encodeURIComponent(post.url.unwrap())}`; } if (post.body.isSome()) { params += `&body=${encodeURIComponent(this.crossPostBody())}`; } return params; } crossPostBody(): string { let post = this.props.post_view.post; let body = `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${post.body .unwrap() .replace(/^/gm, "> ")}`; return body; } 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: Some(event.target.value) }); } handleModRemoveDataChange(i: PostListing, event: any) { i.setState({ removeData: event.target.checked }); } handleModRemoveSubmit(i: PostListing, event: any) { event.preventDefault(); let form = new RemovePost({ post_id: i.props.post_view.post.id, removed: !i.props.post_view.post.removed, reason: i.state.removeReason, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.removePost(form)); i.setState({ showRemoveDialog: false }); } handleModLock(i: PostListing) { let form = new LockPost({ post_id: i.props.post_view.post.id, locked: !i.props.post_view.post.locked, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.lockPost(form)); } handleModSticky(i: PostListing) { let form = new StickyPost({ post_id: i.props.post_view.post.id, stickied: !i.props.post_view.post.stickied, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.stickyPost(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: Some(event.target.value) }); } handlePurgeSubmit(i: PostListing, event: any) { event.preventDefault(); if (i.state.purgeType == PurgeType.Person) { let form = new PurgePerson({ person_id: i.props.post_view.creator.id, reason: i.state.purgeReason, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.purgePerson(form)); } else if (i.state.purgeType == PurgeType.Post) { let form = new PurgePost({ post_id: i.props.post_view.post.id, reason: i.state.purgeReason, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.purgePost(form)); } i.setState({ purgeLoading: true }); } handleModBanReasonChange(i: PostListing, event: any) { i.setState({ banReason: Some(event.target.value) }); } handleModBanExpireDaysChange(i: PostListing, event: any) { i.setState({ banExpireDays: Some(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(); if (i.state.banType == BanType.Community) { // If its an unban, restore all their data let ban = !i.props.post_view.creator_banned_from_community; if (ban == false) { i.setState({ removeData: false }); } let form = new BanFromCommunity({ person_id: i.props.post_view.creator.id, community_id: i.props.post_view.community.id, ban, remove_data: Some(i.state.removeData), reason: i.state.banReason, expires: i.state.banExpireDays.map(futureDaysToUnixTime), auth: auth().unwrap(), }); 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 = new BanPerson({ person_id: i.props.post_view.creator.id, ban, remove_data: Some(i.state.removeData), reason: i.state.banReason, expires: i.state.banExpireDays.map(futureDaysToUnixTime), auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.banPerson(form)); } i.setState({ showBanDialog: false }); } handleAddModToCommunity(i: PostListing) { let form = new AddModToCommunity({ person_id: i.props.post_view.creator.id, community_id: i.props.post_view.community.id, added: !i.creatorIsMod_, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.addModToCommunity(form)); i.setState(i.state); } handleAddAdmin(i: PostListing) { let form = new AddAdmin({ person_id: i.props.post_view.creator.id, added: !i.creatorIsAdmin_, auth: auth().unwrap(), }); 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 form = new TransferCommunity({ community_id: i.props.post_view.community.id, person_id: i.props.post_view.creator.id, auth: auth().unwrap(), }); 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: this.state.score, formattedCount: this.state.score, }); let upvotes = i18n.t("number_of_upvotes", { count: this.state.upvotes, formattedCount: this.state.upvotes, }); let downvotes = i18n.t("number_of_downvotes", { count: this.state.downvotes, formattedCount: this.state.downvotes, }); return `${points} • ${upvotes} • ${downvotes}`; } get canModOnSelf_(): boolean { return canMod( this.props.moderators, this.props.admins, this.props.post_view.creator.id, undefined, true ); } get canMod_(): boolean { return canMod( this.props.moderators, this.props.admins, this.props.post_view.creator.id ); } get canAdmin_(): boolean { return canAdmin(this.props.admins, this.props.post_view.creator.id); } get creatorIsMod_(): boolean { return isMod(this.props.moderators, this.props.post_view.creator.id); } get creatorIsAdmin_(): boolean { return isAdmin(this.props.admins, this.props.post_view.creator.id); } }