import { None, Option, Right, Some } from "@sniptt/monads"; import autosize from "autosize"; import { Component, createRef, linkEvent, RefObject } from "inferno"; import { AddAdminResponse, AddModToCommunityResponse, BanFromCommunityResponse, BanPersonResponse, BlockPersonResponse, CommentNode as CommentNodeI, CommentReportResponse, CommentResponse, CommentSortType, CommunityResponse, GetComments, GetCommentsResponse, GetCommunityResponse, GetPost, GetPostResponse, GetSiteResponse, ListingType, PostReportResponse, PostResponse, PostView, PurgeItemResponse, Search, SearchResponse, SearchType, SortType, toOption, UserOperation, wsJsonToRes, wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; import { CommentViewType, InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, buildCommentsTree, commentsToFlatNodes, commentTreeMaxDepth, createCommentLikeRes, createPostLikeRes, debounce, editCommentRes, enableDownvotes, enableNsfw, getCommentIdFromProps, getCommentParentId, getDepthFromComment, getIdFromProps, insertCommentIntoTree, isBrowser, isImage, restoreScrollPosition, saveCommentRes, saveScrollPosition, setIsoData, setupTippy, toast, trendingFetchLimit, updatePersonBlock, wsClient, wsSubscribe, } from "../../utils"; import { CommentForm } from "../comment/comment-form"; import { CommentNodes } from "../comment/comment-nodes"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; import { Sidebar } from "../community/sidebar"; import { PostListing } from "./post-listing"; const commentsShownInterval = 15; interface PostState { postId: Option; commentId: Option; postRes: Option; commentsRes: Option; commentTree: CommentNodeI[]; commentSort: CommentSortType; commentViewType: CommentViewType; scrolled?: boolean; loading: boolean; crossPosts: Option; siteRes: GetSiteResponse; commentSectionRef?: RefObject; showSidebarMobile: boolean; maxCommentsShown: number; } export class Post extends Component { private subscription: Subscription; private isoData = setIsoData( this.context, GetPostResponse, GetCommentsResponse ); private commentScrollDebounced: () => void; private emptyState: PostState = { postRes: None, commentsRes: None, postId: getIdFromProps(this.props), commentId: getCommentIdFromProps(this.props), commentTree: [], commentSort: CommentSortType[CommentSortType.Hot], commentViewType: CommentViewType.Tree, scrolled: false, loading: true, crossPosts: None, siteRes: this.isoData.site_res, commentSectionRef: null, showSidebarMobile: false, maxCommentsShown: commentsShownInterval, }; constructor(props: any, context: any) { super(props, context); this.state = this.emptyState; this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); this.state = { ...this.state, commentSectionRef: createRef() }; // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { this.state = { ...this.state, postRes: Some(this.isoData.routeData[0] as GetPostResponse), commentsRes: Some(this.isoData.routeData[1] as GetCommentsResponse), }; if (this.state.commentsRes.isSome()) { this.state = { ...this.state, commentTree: buildCommentsTree( this.state.commentsRes.unwrap().comments, this.state.commentId.isSome() ), }; } this.state = { ...this.state, loading: false }; if (isBrowser()) { WebSocketService.Instance.send( wsClient.communityJoin({ community_id: this.state.postRes.unwrap().community_view.community.id, }) ); this.state.postId.match({ some: post_id => WebSocketService.Instance.send(wsClient.postJoin({ post_id })), none: void 0, }); this.fetchCrossPosts(); if (this.checkScrollIntoCommentsParam) { this.scrollIntoCommentSection(); } } } else { this.fetchPost(); } } fetchPost() { this.setState({ commentsRes: None }); let postForm = new GetPost({ id: this.state.postId, comment_id: this.state.commentId, auth: auth(false).ok(), }); WebSocketService.Instance.send(wsClient.getPost(postForm)); let commentsForm = new GetComments({ post_id: this.state.postId, parent_id: this.state.commentId, max_depth: Some(commentTreeMaxDepth), page: None, limit: None, sort: Some(this.state.commentSort), type_: Some(ListingType.All), community_name: None, community_id: None, saved_only: Some(false), auth: auth(false).ok(), }); WebSocketService.Instance.send(wsClient.getComments(commentsForm)); } fetchCrossPosts() { this.state.postRes .andThen(r => r.post_view.post.url) .match({ some: url => { let form = new Search({ q: url, type_: Some(SearchType.Url), sort: Some(SortType.TopAll), listing_type: Some(ListingType.All), page: Some(1), limit: Some(trendingFetchLimit), community_id: None, community_name: None, creator_id: None, auth: auth(false).ok(), }); WebSocketService.Instance.send(wsClient.search(form)); }, none: void 0, }); } static fetchInitialData(req: InitialFetchRequest): Promise[] { let pathSplit = req.path.split("/"); let promises: Promise[] = []; let pathType = pathSplit[1]; let id = Number(pathSplit[2]); let postForm = new GetPost({ id: None, comment_id: None, auth: req.auth, }); let commentsForm = new GetComments({ post_id: None, parent_id: None, max_depth: Some(commentTreeMaxDepth), page: None, limit: None, sort: Some(CommentSortType.Hot), type_: Some(ListingType.All), community_name: None, community_id: None, saved_only: Some(false), auth: req.auth, }); // Set the correct id based on the path type if (pathType == "post") { postForm.id = Some(id); commentsForm.post_id = Some(id); } else { postForm.comment_id = Some(id); commentsForm.parent_id = Some(id); } promises.push(req.client.getPost(postForm)); promises.push(req.client.getComments(commentsForm)); return promises; } componentWillUnmount() { this.subscription.unsubscribe(); document.removeEventListener("scroll", this.commentScrollDebounced); saveScrollPosition(this.context); } componentDidMount() { autosize(document.querySelectorAll("textarea")); this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100); document.addEventListener("scroll", this.commentScrollDebounced); } componentDidUpdate(_lastProps: any) { // Necessary if you are on a post and you click another post (same route) if (_lastProps.location.pathname !== _lastProps.history.location.pathname) { // TODO Couldnt get a refresh working. This does for now. location.reload(); // let currentId = this.props.match.params.id; // WebSocketService.Instance.getPost(currentId); // this.context.refresh(); // this.context.router.history.push(_lastProps.location.pathname); } } get checkScrollIntoCommentsParam() { return Boolean( new URLSearchParams(this.props.location.search).get("scrollToComments") ); } scrollIntoCommentSection() { this.state.commentSectionRef.current?.scrollIntoView(); } isBottom(el: Element): boolean { return el?.getBoundingClientRect().bottom <= window.innerHeight; } /** * Shows new comments when scrolling to the bottom of the comments div */ trackCommentsBoxScrolling = () => { const wrappedElement = document.getElementsByClassName("comments")[0]; if (wrappedElement && this.isBottom(wrappedElement)) { this.setState({ maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval, }); } }; get documentTitle(): string { return this.state.postRes.match({ some: res => `${res.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`, none: "", }); } get imageTag(): Option { return this.state.postRes.match({ some: res => res.post_view.post.thumbnail_url.or( res.post_view.post.url.match({ some: url => (isImage(url) ? Some(url) : None), none: None, }) ), none: None, }); } get descriptionTag(): Option { return this.state.postRes.andThen(r => r.post_view.post.body); } render() { return (
{this.state.loading ? (
) : ( this.state.postRes.match({ some: res => (
{this.state.showSidebarMobile && this.sidebar()}
{this.sortRadios()} {this.state.commentViewType == CommentViewType.Tree && this.commentsTree()} {this.state.commentViewType == CommentViewType.Flat && this.commentsFlat()}
{this.sidebar()}
), none: <>, }) )}
); } sortRadios() { return ( <>
); } commentsFlat() { // These are already sorted by new return this.state.commentsRes.match({ some: commentsRes => this.state.postRes.match({ some: postRes => (
), none: <>, }), none: <>, }); } sidebar() { return this.state.postRes.match({ some: res => (
), none: <>, }); } handleCommentSortChange(i: Post, event: any) { i.setState({ commentSort: CommentSortType[event.target.value], commentViewType: CommentViewType.Tree, }); i.fetchPost(); } handleCommentViewTypeChange(i: Post, event: any) { i.setState({ commentViewType: Number(event.target.value), commentSort: CommentSortType.New, commentTree: buildCommentsTree( i.state.commentsRes.map(r => r.comments).unwrapOr([]), i.state.commentId.isSome() ), }); } handleShowSidebarMobile(i: Post) { i.setState({ showSidebarMobile: !i.state.showSidebarMobile }); } handleViewPost(i: Post) { i.state.postRes.match({ some: res => i.context.router.history.push(`/post/${res.post_view.post.id}`), none: void 0, }); } handleViewContext(i: Post) { i.state.commentsRes.match({ some: res => i.context.router.history.push( `/comment/${getCommentParentId(res.comments[0].comment).unwrap()}` ), none: void 0, }); } commentsTree() { let showContextButton = toOption(this.state.commentTree[0]).match({ some: comment => getDepthFromComment(comment.comment_view.comment) > 0, none: false, }); return this.state.postRes.match({ some: res => (
{this.state.commentId.isSome() && ( <> {showContextButton && ( )} )}
), none: <>, }); } parseMessage(msg: any) { let op = wsUserOp(msg); console.log(msg); if (msg.error) { toast(i18n.t(msg.error), "danger"); return; } else if (msg.reconnect) { this.state.postRes.match({ some: res => { let postId = res.post_view.post.id; WebSocketService.Instance.send( wsClient.postJoin({ post_id: postId }) ); WebSocketService.Instance.send( wsClient.getPost({ id: Some(postId), comment_id: None, auth: auth(false).ok(), }) ); }, none: void 0, }); } else if (op == UserOperation.GetPost) { let data = wsJsonToRes(msg, GetPostResponse); this.setState({ postRes: Some(data) }); // join the rooms WebSocketService.Instance.send( wsClient.postJoin({ post_id: data.post_view.post.id }) ); WebSocketService.Instance.send( wsClient.communityJoin({ community_id: data.community_view.community.id, }) ); // Get cross-posts // TODO move this into initial fetch and refetch this.fetchCrossPosts(); setupTippy(); if (this.state.commentId.isNone()) restoreScrollPosition(this.context); if (this.checkScrollIntoCommentsParam) { this.scrollIntoCommentSection(); } } else if (op == UserOperation.GetComments) { let data = wsJsonToRes(msg, GetCommentsResponse); // You might need to append here, since this could be building more comments from a tree fetch this.state.commentsRes.match({ some: res => { // Remove the first comment, since it is the parent let newComments = data.comments; newComments.shift(); res.comments.push(...newComments); }, none: () => this.setState({ commentsRes: Some(data) }), }); // this.state.commentsRes = Some(data); this.setState({ commentTree: buildCommentsTree( this.state.commentsRes.map(r => r.comments).unwrapOr([]), this.state.commentId.isSome() ), loading: false, }); this.setState(this.state); } else if (op == UserOperation.CreateComment) { let data = wsJsonToRes(msg, CommentResponse); // Don't get comments from the post room, if the creator is blocked let creatorBlocked = UserService.Instance.myUserInfo .map(m => m.person_blocks) .unwrapOr([]) .map(pb => pb.target.id) .includes(data.comment_view.creator.id); // Necessary since it might be a user reply, which has the recipients, to avoid double if (data.recipient_ids.length == 0 && !creatorBlocked) { this.state.postRes.match({ some: postRes => this.state.commentsRes.match({ some: commentsRes => { commentsRes.comments.unshift(data.comment_view); insertCommentIntoTree( this.state.commentTree, data.comment_view, this.state.commentId.isSome() ); postRes.post_view.counts.comments++; }, none: void 0, }), none: void 0, }); this.setState(this.state); setupTippy(); } } else if ( op == UserOperation.EditComment || op == UserOperation.DeleteComment || op == UserOperation.RemoveComment ) { let data = wsJsonToRes(msg, CommentResponse); editCommentRes( data.comment_view, this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); setupTippy(); } else if (op == UserOperation.SaveComment) { let data = wsJsonToRes(msg, CommentResponse); saveCommentRes( data.comment_view, this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); setupTippy(); } else if (op == UserOperation.CreateCommentLike) { let data = wsJsonToRes(msg, CommentResponse); createCommentLikeRes( data.comment_view, this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { let data = wsJsonToRes(msg, PostResponse); this.state.postRes.match({ some: res => createPostLikeRes(data.post_view, res.post_view), none: void 0, }); this.setState(this.state); } else if ( op == UserOperation.EditPost || op == UserOperation.DeletePost || op == UserOperation.RemovePost || op == UserOperation.LockPost || op == UserOperation.FeaturePost || op == UserOperation.SavePost ) { let data = wsJsonToRes(msg, PostResponse); this.state.postRes.match({ some: res => (res.post_view = data.post_view), none: void 0, }); this.setState(this.state); setupTippy(); } else if ( op == UserOperation.EditCommunity || op == UserOperation.DeleteCommunity || op == UserOperation.RemoveCommunity || op == UserOperation.FollowCommunity ) { let data = wsJsonToRes(msg, CommunityResponse); this.state.postRes.match({ some: res => { res.community_view = data.community_view; res.post_view.community = data.community_view.community; this.setState(this.state); }, none: void 0, }); } else if (op == UserOperation.BanFromCommunity) { let data = wsJsonToRes( msg, BanFromCommunityResponse ); this.state.postRes.match({ some: postRes => this.state.commentsRes.match({ some: commentsRes => { commentsRes.comments .filter(c => c.creator.id == data.person_view.person.id) .forEach(c => (c.creator_banned_from_community = data.banned)); if (postRes.post_view.creator.id == data.person_view.person.id) { postRes.post_view.creator_banned_from_community = data.banned; } this.setState(this.state); }, none: void 0, }), none: void 0, }); } else if (op == UserOperation.AddModToCommunity) { let data = wsJsonToRes( msg, AddModToCommunityResponse ); this.state.postRes.match({ some: res => { res.moderators = data.moderators; this.setState(this.state); }, none: void 0, }); } else if (op == UserOperation.BanPerson) { let data = wsJsonToRes(msg, BanPersonResponse); this.state.postRes.match({ some: postRes => this.state.commentsRes.match({ some: commentsRes => { commentsRes.comments .filter(c => c.creator.id == data.person_view.person.id) .forEach(c => (c.creator.banned = data.banned)); if (postRes.post_view.creator.id == data.person_view.person.id) { postRes.post_view.creator.banned = data.banned; } this.setState(this.state); }, none: void 0, }), none: void 0, }); } else if (op == UserOperation.AddAdmin) { let data = wsJsonToRes(msg, AddAdminResponse); this.setState(s => ((s.siteRes.admins = data.admins), s)); } else if (op == UserOperation.Search) { let data = wsJsonToRes(msg, SearchResponse); let xPosts = data.posts.filter( p => p.post.id != Number(this.props.match.params.id) ); this.setState({ crossPosts: xPosts.length > 0 ? Some(xPosts) : None }); } else if (op == UserOperation.LeaveAdmin) { let data = wsJsonToRes(msg, GetSiteResponse); this.setState({ siteRes: data }); } else if (op == UserOperation.TransferCommunity) { let data = wsJsonToRes(msg, GetCommunityResponse); this.state.postRes.match({ some: res => { res.community_view = data.community_view; res.post_view.community = data.community_view.community; res.moderators = data.moderators; this.setState(this.state); }, none: void 0, }); } else if (op == UserOperation.BlockPerson) { let data = wsJsonToRes(msg, BlockPersonResponse); updatePersonBlock(data); } else if (op == UserOperation.CreatePostReport) { let data = wsJsonToRes(msg, PostReportResponse); if (data) { toast(i18n.t("report_created")); } } else if (op == UserOperation.CreateCommentReport) { let data = wsJsonToRes(msg, CommentReportResponse); if (data) { toast(i18n.t("report_created")); } } else if ( op == UserOperation.PurgePerson || op == UserOperation.PurgePost || op == UserOperation.PurgeComment || op == UserOperation.PurgeCommunity ) { let data = wsJsonToRes(msg, PurgeItemResponse); if (data.success) { toast(i18n.t("purge_success")); this.context.router.history.push(`/`); } } } }