From 69b623b8fb755a6b254757146caaf3ef87b74aa3 Mon Sep 17 00:00:00 2001 From: Dessalines <dessalines@users.noreply.github.com> Date: Sat, 30 Jul 2022 09:28:08 -0400 Subject: [PATCH] Comment Tree paging (#726) * Updating translations. * Forgot to add comment-sort-select * Upgrading deps --- lemmy-translations | 2 +- package.json | 2 +- .../components/comment/comment-form.tsx | 2 +- .../components/comment/comment-node.tsx | 138 ++++-- .../components/comment/comment-nodes.tsx | 10 +- .../components/comment/comment-report.tsx | 7 +- .../components/common/comment-sort-select.tsx | 70 ++++ src/shared/components/common/sort-select.tsx | 1 + src/shared/components/community/community.tsx | 18 +- src/shared/components/home/home.tsx | 18 +- src/shared/components/person/inbox.tsx | 185 ++++---- .../components/person/person-details.tsx | 6 +- src/shared/components/post/post-listing.tsx | 52 +-- src/shared/components/post/post.tsx | 395 +++++++++++------- src/shared/components/search.tsx | 12 +- src/shared/interfaces.ts | 22 +- src/shared/routes.ts | 4 +- src/shared/utils.ts | 143 +++---- yarn.lock | 8 +- 19 files changed, 652 insertions(+), 443 deletions(-) create mode 100644 src/shared/components/common/comment-sort-select.tsx diff --git a/lemmy-translations b/lemmy-translations index 7c1b691..7c39457 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit 7c1b691af63845a2fe2f8219b4620b8db3c9c3ba +Subproject commit 7c3945745dcd07774b19453803f7f14ab80ab3d3 diff --git a/package.json b/package.json index 4213e89..31e9c87 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.1", "import-sort-style-module": "^6.0.0", - "lemmy-js-client": "0.17.0-rc.38", + "lemmy-js-client": "0.17.0-rc.39", "lint-staged": "^13.0.3", "mini-css-extract-plugin": "^2.6.1", "node-fetch": "^2.6.1", diff --git a/src/shared/components/comment/comment-form.tsx b/src/shared/components/comment/comment-form.tsx index 7abf39b..64841e7 100644 --- a/src/shared/components/comment/comment-form.tsx +++ b/src/shared/components/comment/comment-form.tsx @@ -3,6 +3,7 @@ import { Component } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; import { + CommentNode as CommentNodeI, CommentResponse, CreateComment, EditComment, @@ -12,7 +13,6 @@ import { } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { CommentNode as CommentNodeI } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, diff --git a/src/shared/components/comment/comment-node.tsx b/src/shared/components/comment/comment-node.tsx index 27e209e..d4bd1fe 100644 --- a/src/shared/components/comment/comment-node.tsx +++ b/src/shared/components/comment/comment-node.tsx @@ -8,12 +8,16 @@ import { BanFromCommunity, BanPerson, BlockPerson, + CommentNode as CommentNodeI, + CommentReplyView, CommentView, CommunityModeratorView, CreateCommentLike, CreateCommentReport, DeleteComment, - MarkCommentAsRead, + GetComments, + ListingType, + MarkCommentReplyAsRead, MarkPersonMentionAsRead, PersonMentionView, PersonViewSafe, @@ -26,11 +30,7 @@ import { } from "lemmy-js-client"; import moment from "moment"; import { i18n } from "../../i18next"; -import { - BanType, - CommentNode as CommentNodeI, - PurgeType, -} from "../../interfaces"; +import { BanType, CommentViewType, PurgeType } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { amCommunityCreator, @@ -38,6 +38,7 @@ import { canAdmin, canMod, colorList, + commentTreeMaxDepth, futureDaysToUnixTime, isAdmin, isBanned, @@ -82,7 +83,6 @@ interface CommentNodeState { score: number; upvotes: number; downvotes: number; - borderColor: string; readLoading: boolean; saveLoading: boolean; } @@ -99,6 +99,7 @@ interface CommentNodeProps { showContext?: boolean; showCommunity?: boolean; enableDownvotes: boolean; + viewType: CommentViewType; } export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { @@ -129,9 +130,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { score: this.props.node.comment_view.counts.score, upvotes: this.props.node.comment_view.counts.upvotes, downvotes: this.props.node.comment_view.counts.downvotes, - borderColor: this.props.node.depth - ? colorList[this.props.node.depth % colorList.length] - : colorList[0], readLoading: false, saveLoading: false, }; @@ -181,10 +179,23 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { cv.creator.id ); + let borderColor = this.props.node.depth + ? colorList[(this.props.node.depth - 1) % colorList.length] + : colorList[0]; + let moreRepliesBorderColor = this.props.node.depth + ? colorList[this.props.node.depth % colorList.length] + : colorList[0]; + + let showMoreChildren = + this.props.viewType == CommentViewType.Tree && + !this.state.collapsed && + node.children.length == 0 && + node.comment_view.counts.child_count > 0; + return ( <div className={`comment ${ - cv.comment.parent_id.isSome() && !this.props.noIndent ? "ml-1" : "" + this.props.node.depth && !this.props.noIndent ? "ml-1" : "" }`} > <div @@ -194,14 +205,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } ${this.isCommentNew ? "mark" : ""}`} style={ !this.props.noIndent && - cv.comment.parent_id.isSome() && - `border-left: 2px ${this.state.borderColor} solid !important` + this.props.node.depth && + `border-left: 2px ${borderColor} solid !important` } > <div - class={`${ - !this.props.noIndent && cv.comment.parent_id.isSome() && "ml-2" - }`} + class={`${!this.props.noIndent && this.props.node.depth && "ml-2"}`} > <div class="d-flex flex-wrap align-items-center text-muted small"> <span class="mr-2"> @@ -262,7 +271,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { <> <a className={`unselectable pointer ${this.scoreColor}`} - onClick={linkEvent(node, this.handleCommentUpvote)} + onClick={this.handleCommentUpvote} data-tippy-content={this.pointsTippy} > <span @@ -314,12 +323,12 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { class="btn btn-link btn-animate text-muted" onClick={linkEvent(this, this.handleMarkRead)} data-tippy-content={ - this.commentOrMentionRead + this.commentReplyOrMentionRead ? i18n.t("mark_as_unread") : i18n.t("mark_as_read") } aria-label={ - this.commentOrMentionRead + this.commentReplyOrMentionRead ? i18n.t("mark_as_unread") : i18n.t("mark_as_read") } @@ -330,7 +339,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { <Icon icon="check" classes={`icon-inline ${ - this.commentOrMentionRead && "text-success" + this.commentReplyOrMentionRead && "text-success" }`} /> )} @@ -345,7 +354,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { ? "text-info" : "text-muted" }`} - onClick={linkEvent(node, this.handleCommentUpvote)} + onClick={this.handleCommentUpvote} data-tippy-content={i18n.t("upvote")} aria-label={i18n.t("upvote")} > @@ -364,10 +373,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { ? "text-danger" : "text-muted" }`} - onClick={linkEvent( - node, - this.handleCommentDownvote - )} + onClick={this.handleCommentDownvote} data-tippy-content={i18n.t("downvote")} aria-label={i18n.t("downvote")} > @@ -772,6 +778,25 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { )} </div> </div> + {showMoreChildren && ( + <div + className={`details ml-1 comment-node py-2 ${ + !this.props.noBorder ? "border-top border-light" : "" + }`} + style={`border-left: 2px ${moreRepliesBorderColor} solid !important`} + > + <button + class="btn btn-link text-muted" + onClick={linkEvent(this, this.handleFetchChildren)} + > + {i18n.t("x_more_replies", { + count: node.comment_view.counts.child_count, + formattedCount: numToSI(node.comment_view.counts.child_count), + })}{" "} + â + </button> + </div> + )} {/* end of details */} {this.state.showRemoveDialog && ( <form @@ -931,7 +956,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { focus /> )} - {!this.state.collapsed && node.children && ( + {!this.state.collapsed && node.children.length > 0 && ( <CommentNodes nodes={node.children} locked={this.props.locked} @@ -939,6 +964,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { admins={this.props.admins} maxCommentsShown={None} enableDownvotes={this.props.enableDownvotes} + viewType={this.props.viewType} /> )} {/* A collapsed clearfix */} @@ -947,11 +973,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { ); } - get commentOrMentionRead() { + get commentReplyOrMentionRead(): boolean { let cv = this.props.node.comment_view; - return this.isPersonMentionType(cv) - ? cv.person_mention.read - : cv.comment.read; + + if (this.isPersonMentionType(cv)) { + return cv.person_mention.read; + } else if (this.isCommentReplyType(cv)) { + return cv.comment_reply.read; + } else { + return false; + } } linkBtn(small = false) { @@ -968,7 +999,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { <> <Link className={classnames} - to={`/post/${cv.post.id}/comment/${cv.comment.id}`} + to={`/comment/${cv.comment.id}`} title={title} > <Icon icon="link" classes="icon-inline" /> @@ -1061,7 +1092,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.setState(this.state); } - handleCommentUpvote(i: CommentNodeI, event: any) { + handleCommentUpvote(event: any) { event.preventDefault(); let myVote = this.state.my_vote.unwrapOr(0); let newVote = myVote == 1 ? 0 : 1; @@ -1081,17 +1112,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.state.my_vote = Some(newVote); let form = new CreateCommentLike({ - comment_id: i.comment_view.comment.id, + comment_id: this.props.node.comment_view.comment.id, score: newVote, auth: auth().unwrap(), }); - WebSocketService.Instance.send(wsClient.likeComment(form)); this.setState(this.state); setupTippy(); } - handleCommentDownvote(i: CommentNodeI, event: any) { + handleCommentDownvote(event: any) { event.preventDefault(); let myVote = this.state.my_vote.unwrapOr(0); let newVote = myVote == -1 ? 0 : -1; @@ -1111,7 +1141,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.state.my_vote = Some(newVote); let form = new CreateCommentLike({ - comment_id: i.comment_view.comment.id, + comment_id: this.props.node.comment_view.comment.id, score: newVote, auth: auth().unwrap(), }); @@ -1175,11 +1205,17 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } isPersonMentionType( - item: CommentView | PersonMentionView + item: CommentView | PersonMentionView | CommentReplyView ): item is PersonMentionView { return (item as PersonMentionView).person_mention?.id !== undefined; } + isCommentReplyType( + item: CommentView | PersonMentionView | CommentReplyView + ): item is CommentReplyView { + return (item as CommentReplyView).comment_reply?.id !== undefined; + } + handleMarkRead(i: CommentNode) { if (i.isPersonMentionType(i.props.node.comment_view)) { let form = new MarkPersonMentionAsRead({ @@ -1188,13 +1224,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form)); - } else { - let form = new MarkCommentAsRead({ - comment_id: i.props.node.comment_view.comment.id, - read: !i.props.node.comment_view.comment.read, + } else if (i.isCommentReplyType(i.props.node.comment_view)) { + let form = new MarkCommentReplyAsRead({ + comment_reply_id: i.props.node.comment_view.comment_reply.id, + read: !i.props.node.comment_view.comment_reply.read, auth: auth().unwrap(), }); - WebSocketService.Instance.send(wsClient.markCommentAsRead(form)); + WebSocketService.Instance.send(wsClient.markCommentReplyAsRead(form)); } i.state.readLoading = true; @@ -1419,6 +1455,24 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { setupTippy(); } + handleFetchChildren(i: CommentNode) { + let form = new GetComments({ + post_id: Some(i.props.node.comment_view.post.id), + parent_id: Some(i.props.node.comment_view.comment.id), + max_depth: Some(commentTreeMaxDepth), + page: None, + sort: None, + limit: Some(999), + type_: Some(ListingType.All), + community_name: None, + community_id: None, + saved_only: Some(false), + auth: auth(false).ok(), + }); + + WebSocketService.Instance.send(wsClient.getComments(form)); + } + get scoreColor() { if (this.state.my_vote.unwrapOr(0) == 1) { return "text-info"; diff --git a/src/shared/components/comment/comment-nodes.tsx b/src/shared/components/comment/comment-nodes.tsx index 62167ec..f9484c2 100644 --- a/src/shared/components/comment/comment-nodes.tsx +++ b/src/shared/components/comment/comment-nodes.tsx @@ -1,7 +1,11 @@ import { Option } from "@sniptt/monads"; import { Component } from "inferno"; -import { CommunityModeratorView, PersonViewSafe } from "lemmy-js-client"; -import { CommentNode as CommentNodeI } from "../../interfaces"; +import { + CommentNode as CommentNodeI, + CommunityModeratorView, + PersonViewSafe, +} from "lemmy-js-client"; +import { CommentViewType } from "../../interfaces"; import { CommentNode } from "./comment-node"; interface CommentNodesProps { @@ -17,6 +21,7 @@ interface CommentNodesProps { showContext?: boolean; showCommunity?: boolean; enableDownvotes?: boolean; + viewType: CommentViewType; } export class CommentNodes extends Component<CommentNodesProps, any> { @@ -45,6 +50,7 @@ export class CommentNodes extends Component<CommentNodesProps, any> { showContext={this.props.showContext} showCommunity={this.props.showCommunity} enableDownvotes={this.props.enableDownvotes} + viewType={this.props.viewType} /> ))} </div> diff --git a/src/shared/components/comment/comment-report.tsx b/src/shared/components/comment/comment-report.tsx index 0a65226..a2d2b10 100644 --- a/src/shared/components/comment/comment-report.tsx +++ b/src/shared/components/comment/comment-report.tsx @@ -2,13 +2,14 @@ import { None } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { + CommentNode as CommentNodeI, CommentReportView, CommentView, ResolveCommentReport, SubscribedType, } from "lemmy-js-client"; import { i18n } from "../../i18next"; -import { CommentNode as CommentNodeI } from "../../interfaces"; +import { CommentViewType } from "../../interfaces"; import { WebSocketService } from "../../services"; import { auth, wsClient } from "../../utils"; import { Icon } from "../common/icon"; @@ -44,18 +45,20 @@ export class CommentReport extends Component<CommentReportProps, any> { subscribed: SubscribedType.NotSubscribed, saved: false, creator_blocked: false, - recipient: None, my_vote: r.my_vote, }; let node: CommentNodeI = { comment_view, + children: [], + depth: 0, }; return ( <div> <CommentNode node={node} + viewType={CommentViewType.Flat} moderators={None} admins={None} enableDownvotes={true} diff --git a/src/shared/components/common/comment-sort-select.tsx b/src/shared/components/common/comment-sort-select.tsx new file mode 100644 index 0000000..b87f266 --- /dev/null +++ b/src/shared/components/common/comment-sort-select.tsx @@ -0,0 +1,70 @@ +import { Component, linkEvent } from "inferno"; +import { CommentSortType } from "lemmy-js-client"; +import { i18n } from "../../i18next"; +import { randomStr, relTags, sortingHelpUrl } from "../../utils"; +import { Icon } from "./icon"; + +interface CommentSortSelectProps { + sort: CommentSortType; + onChange?(val: CommentSortType): any; +} + +interface CommentSortSelectState { + sort: CommentSortType; +} + +export class CommentSortSelect extends Component< + CommentSortSelectProps, + CommentSortSelectState +> { + private id = `sort-select-${randomStr()}`; + private emptyState: CommentSortSelectState = { + sort: this.props.sort, + }; + + constructor(props: any, context: any) { + super(props, context); + this.state = this.emptyState; + } + + static getDerivedStateFromProps(props: any): CommentSortSelectState { + return { + sort: props.sort, + }; + } + + render() { + return ( + <> + <select + id={this.id} + name={this.id} + value={this.state.sort} + onChange={linkEvent(this, this.handleSortChange)} + class="custom-select w-auto mr-2 mb-2" + aria-label={i18n.t("sort_type")} + > + <option disabled aria-hidden="true"> + {i18n.t("sort_type")} + </option> + <option value={CommentSortType.Hot}>{i18n.t("hot")}</option>, + <option value={CommentSortType.Top}>{i18n.t("top")}</option>, + <option value={CommentSortType.New}>{i18n.t("new")}</option> + <option value={CommentSortType.Old}>{i18n.t("old")}</option> + </select> + <a + className="text-muted" + href={sortingHelpUrl} + rel={relTags} + title={i18n.t("sorting_help")} + > + <Icon icon="help-circle" classes="icon-inline" /> + </a> + </> + ); + } + + handleSortChange(i: CommentSortSelect, event: any) { + i.props.onChange(event.target.value); + } +} diff --git a/src/shared/components/common/sort-select.tsx b/src/shared/components/common/sort-select.tsx index 1188064..3f815f5 100644 --- a/src/shared/components/common/sort-select.tsx +++ b/src/shared/components/common/sort-select.tsx @@ -51,6 +51,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> { <option value={SortType.Active}>{i18n.t("active")}</option>, ]} <option value={SortType.New}>{i18n.t("new")}</option> + <option value={SortType.Old}>{i18n.t("old")}</option> {!this.props.hideMostComments && [ <option value={SortType.MostComments}> {i18n.t("most_comments")} diff --git a/src/shared/components/community/community.tsx b/src/shared/components/community/community.tsx index c5afe0e..4e8d949 100644 --- a/src/shared/components/community/community.tsx +++ b/src/shared/components/community/community.tsx @@ -29,7 +29,11 @@ import { } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { DataType, InitialFetchRequest } from "../../interfaces"; +import { + CommentViewType, + DataType, + InitialFetchRequest, +} from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, @@ -46,6 +50,7 @@ import { getPageFromProps, getSortTypeFromProps, notifyPost, + postToCommentSortType, relTags, restoreScrollPosition, saveCommentRes, @@ -233,9 +238,12 @@ export class Community extends Component<any, State> { community_id: None, page, limit: Some(fetchLimit), - sort, + max_depth: None, + sort: sort.map(postToCommentSortType), type_: Some(ListingType.All), saved_only: Some(false), + post_id: None, + parent_id: None, auth: req.auth, }); promises.push(Promise.resolve()); @@ -389,6 +397,7 @@ export class Community extends Component<any, State> { ) : ( <CommentNodes nodes={commentsToFlatNodes(this.state.comments)} + viewType={CommentViewType.Flat} noIndent showContext enableDownvotes={enableDownvotes(this.state.siteRes)} @@ -499,11 +508,14 @@ export class Community extends Component<any, State> { let form = new GetComments({ page: Some(this.state.page), limit: Some(fetchLimit), - sort: Some(this.state.sort), + max_depth: None, + sort: Some(postToCommentSortType(this.state.sort)), type_: Some(ListingType.All), community_name: Some(this.state.communityName), community_id: None, saved_only: Some(false), + post_id: None, + parent_id: None, auth: auth(false).ok(), }); WebSocketService.Instance.send(wsClient.getComments(form)); diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index 6618034..53559df 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -30,7 +30,11 @@ import { } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { DataType, InitialFetchRequest } from "../../interfaces"; +import { + CommentViewType, + DataType, + InitialFetchRequest, +} from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, @@ -48,6 +52,7 @@ import { getSortTypeFromProps, isBrowser, notifyPost, + postToCommentSortType, relTags, restoreScrollPosition, saveCommentRes, @@ -263,9 +268,12 @@ export class Home extends Component<any, HomeState> { community_name: None, page, limit: Some(fetchLimit), - sort, + max_depth: None, + sort: sort.map(postToCommentSortType), type_, saved_only: Some(false), + post_id: None, + parent_id: None, auth: req.auth, }); promises.push(Promise.resolve()); @@ -565,6 +573,7 @@ export class Home extends Component<any, HomeState> { ) : ( <CommentNodes nodes={commentsToFlatNodes(this.state.comments)} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -694,8 +703,11 @@ export class Home extends Component<any, HomeState> { community_name: None, page: Some(this.state.page), limit: Some(fetchLimit), - sort: Some(this.state.sort), + max_depth: None, + sort: Some(postToCommentSortType(this.state.sort)), saved_only: Some(false), + post_id: None, + parent_id: None, auth: auth(false).ok(), type_: Some(this.state.listingType), }); diff --git a/src/shared/components/person/inbox.tsx b/src/shared/components/person/inbox.tsx index e2faffc..2a8a7b2 100644 --- a/src/shared/components/person/inbox.tsx +++ b/src/shared/components/person/inbox.tsx @@ -2,8 +2,11 @@ import { None, Some } from "@sniptt/monads"; import { Component, linkEvent } from "inferno"; import { BlockPersonResponse, + CommentReplyResponse, + CommentReplyView, CommentReportResponse, CommentResponse, + CommentSortType, CommentView, GetPersonMentions, GetPersonMentionsResponse, @@ -17,14 +20,13 @@ import { PrivateMessageResponse, PrivateMessagesResponse, PrivateMessageView, - SortType, UserOperation, wsJsonToRes, wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { InitialFetchRequest } from "../../interfaces"; +import { CommentViewType, InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, @@ -44,10 +46,10 @@ import { wsSubscribe, } from "../../utils"; import { CommentNodes } from "../comment/comment-nodes"; +import { CommentSortSelect } from "../common/comment-sort-select"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; import { Paginator } from "../common/paginator"; -import { SortSelect } from "../common/sort-select"; import { PrivateMessage } from "../private_message/private-message"; enum UnreadOrAll { @@ -70,18 +72,18 @@ enum ReplyEnum { type ReplyType = { id: number; type_: ReplyEnum; - view: CommentView | PrivateMessageView | PersonMentionView; + view: CommentView | PrivateMessageView | PersonMentionView | CommentReplyView; published: string; }; interface InboxState { unreadOrAll: UnreadOrAll; messageType: MessageType; - replies: CommentView[]; + replies: CommentReplyView[]; mentions: PersonMentionView[]; messages: PrivateMessageView[]; combined: ReplyType[]; - sort: SortType; + sort: CommentSortType; page: number; siteRes: GetSiteResponse; loading: boolean; @@ -102,7 +104,7 @@ export class Inbox extends Component<any, InboxState> { mentions: [], messages: [], combined: [], - sort: SortType.New, + sort: CommentSortType.New, page: 1, siteRes: this.isoData.site_res, loading: true, @@ -323,19 +325,17 @@ export class Inbox extends Component<any, InboxState> { <div className="mb-2"> <span class="mr-3">{this.unreadOrAllRadios()}</span> <span class="mr-3">{this.messageTypeRadios()}</span> - <SortSelect + <CommentSortSelect sort={this.state.sort} onChange={this.handleSortChange} - hideHot - hideMostComments /> </div> ); } - replyToReplyType(r: CommentView): ReplyType { + replyToReplyType(r: CommentReplyView): ReplyType { return { - id: r.comment.id, + id: r.comment_reply.id, type_: ReplyEnum.Reply, view: r, published: r.comment.published, @@ -382,7 +382,10 @@ export class Inbox extends Component<any, InboxState> { return ( <CommentNodes key={i.id} - nodes={[{ comment_view: i.view as CommentView }]} + nodes={[ + { comment_view: i.view as CommentView, children: [], depth: 0 }, + ]} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -397,7 +400,14 @@ export class Inbox extends Component<any, InboxState> { return ( <CommentNodes key={i.id} - nodes={[{ comment_view: i.view as PersonMentionView }]} + nodes={[ + { + comment_view: i.view as PersonMentionView, + children: [], + depth: 0, + }, + ]} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -429,6 +439,7 @@ export class Inbox extends Component<any, InboxState> { <div> <CommentNodes nodes={commentsToFlatNodes(this.state.replies)} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -448,7 +459,8 @@ export class Inbox extends Component<any, InboxState> { {this.state.mentions.map(umv => ( <CommentNodes key={umv.person_mention.id} - nodes={[{ comment_view: umv }]} + nodes={[{ comment_view: umv, children: [], depth: 0 }]} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -498,9 +510,11 @@ export class Inbox extends Component<any, InboxState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let promises: Promise<any>[] = []; + let sort = Some(CommentSortType.New); + // It can be /u/me, or /username/1 let repliesForm = new GetReplies({ - sort: Some(SortType.New), + sort, unread_only: Some(true), page: Some(1), limit: Some(fetchLimit), @@ -509,7 +523,7 @@ export class Inbox extends Component<any, InboxState> { promises.push(req.client.getReplies(repliesForm)); let personMentionsForm = new GetPersonMentions({ - sort: Some(SortType.New), + sort, unread_only: Some(true), page: Some(1), limit: Some(fetchLimit), @@ -565,7 +579,7 @@ export class Inbox extends Component<any, InboxState> { ); } - handleSortChange(val: SortType) { + handleSortChange(val: CommentSortType) { this.state.sort = val; this.state.page = 1; this.setState(this.state); @@ -581,6 +595,7 @@ export class Inbox extends Component<any, InboxState> { i.state.replies = []; i.state.mentions = []; i.state.messages = []; + i.state.combined = i.buildCombined(); UserService.Instance.unreadInboxCountSub.next(0); window.scrollTo(0, 0); i.setState(i.state); @@ -716,34 +731,51 @@ export class Inbox extends Component<any, InboxState> { let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); editCommentRes(data.comment_view, this.state.replies); this.setState(this.state); - } else if (op == UserOperation.MarkCommentAsRead) { - let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); + } else if (op == UserOperation.MarkCommentReplyAsRead) { + let data = wsJsonToRes<CommentReplyResponse>(msg, CommentReplyResponse); + console.log(data); - // If youre in the unread view, just remove it from the list - if ( - this.state.unreadOrAll == UnreadOrAll.Unread && - data.comment_view.comment.read - ) { - this.state.replies = this.state.replies.filter( - r => r.comment.id !== data.comment_view.comment.id - ); - this.state.combined = this.state.combined.filter( - r => r.id !== data.comment_view.comment.id - ); - } else { - let found = this.state.replies.find( - c => c.comment.id == data.comment_view.comment.id - ); + let found = this.state.replies.find( + c => c.comment_reply.id == data.comment_reply_view.comment_reply.id + ); + + if (found) { let combinedView = this.state.combined.find( - i => i.id == data.comment_view.comment.id - ).view as CommentView; - found.comment.read = combinedView.comment.read = - data.comment_view.comment.read; - } + i => i.id == data.comment_reply_view.comment_reply.id + ).view as CommentReplyView; + found.comment.content = combinedView.comment.content = + data.comment_reply_view.comment.content; + found.comment.updated = combinedView.comment.updated = + data.comment_reply_view.comment.updated; + found.comment.removed = combinedView.comment.removed = + data.comment_reply_view.comment.removed; + found.comment.deleted = combinedView.comment.deleted = + data.comment_reply_view.comment.deleted; + found.counts.upvotes = combinedView.counts.upvotes = + data.comment_reply_view.counts.upvotes; + found.counts.downvotes = combinedView.counts.downvotes = + data.comment_reply_view.counts.downvotes; + found.counts.score = combinedView.counts.score = + data.comment_reply_view.counts.score; - this.sendUnreadCount(data.comment_view.comment.read); + // If youre in the unread view, just remove it from the list + if ( + this.state.unreadOrAll == UnreadOrAll.Unread && + data.comment_reply_view.comment_reply.read + ) { + this.state.replies = this.state.replies.filter( + r => r.comment_reply.id !== data.comment_reply_view.comment_reply.id + ); + this.state.combined = this.state.combined.filter( + r => r.id !== data.comment_reply_view.comment_reply.id + ); + } else { + found.comment_reply.read = combinedView.comment_reply.read = + data.comment_reply_view.comment_reply.read; + } + } + this.sendUnreadCount(data.comment_reply_view.comment_reply.read); this.setState(this.state); - setupTippy(); } else if (op == UserOperation.MarkPersonMentionAsRead) { let data = wsJsonToRes<PersonMentionResponse>(msg, PersonMentionResponse); @@ -791,71 +823,6 @@ export class Inbox extends Component<any, InboxState> { } this.sendUnreadCount(data.person_mention_view.person_mention.read); this.setState(this.state); - } else if (op == UserOperation.CreateComment) { - let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); - - UserService.Instance.myUserInfo.match({ - some: mui => { - if (data.recipient_ids.includes(mui.local_user_view.local_user.id)) { - this.state.replies.unshift(data.comment_view); - this.state.combined.unshift( - this.replyToReplyType(data.comment_view) - ); - this.setState(this.state); - } else if ( - data.comment_view.creator.id == mui.local_user_view.person.id - ) { - // If youre in the unread view, just remove it from the list - if (this.state.unreadOrAll == UnreadOrAll.Unread) { - this.state.replies = this.state.replies.filter( - r => - r.comment.id !== - data.comment_view.comment.parent_id.unwrapOr(0) - ); - this.state.mentions = this.state.mentions.filter( - m => - m.comment.id !== - data.comment_view.comment.parent_id.unwrapOr(0) - ); - this.state.combined = this.state.combined.filter(r => { - if (this.isMention(r.view)) - return ( - r.view.comment.id !== - data.comment_view.comment.parent_id.unwrapOr(0) - ); - else - return ( - r.id !== data.comment_view.comment.parent_id.unwrapOr(0) - ); - }); - } else { - let mention_found = this.state.mentions.find( - i => - i.comment.id == - data.comment_view.comment.parent_id.unwrapOr(0) - ); - if (mention_found) { - mention_found.person_mention.read = true; - } - let reply_found = this.state.replies.find( - i => - i.comment.id == - data.comment_view.comment.parent_id.unwrapOr(0) - ); - if (reply_found) { - reply_found.comment.read = true; - } - this.state.combined = this.buildCombined(); - } - this.sendUnreadCount(true); - this.setState(this.state); - setupTippy(); - // TODO this seems wrong, you should be using form_id - toast(i18n.t("reply_sent")); - } - }, - none: void 0, - }); } else if (op == UserOperation.CreatePrivateMessage) { let data = wsJsonToRes<PrivateMessageResponse>( msg, @@ -904,4 +871,8 @@ export class Inbox extends Component<any, InboxState> { isMention(view: any): view is PersonMentionView { return (view as PersonMentionView).person_mention !== undefined; } + + isReply(view: any): view is CommentReplyView { + return (view as CommentReplyView).comment_reply !== undefined; + } } diff --git a/src/shared/components/person/person-details.tsx b/src/shared/components/person/person-details.tsx index 0dabf2a..6ce7a8b 100644 --- a/src/shared/components/person/person-details.tsx +++ b/src/shared/components/person/person-details.tsx @@ -7,7 +7,7 @@ import { PostView, SortType, } from "lemmy-js-client"; -import { PersonDetailsView } from "../../interfaces"; +import { CommentViewType, PersonDetailsView } from "../../interfaces"; import { commentsToFlatNodes, setupTippy } from "../../utils"; import { CommentNodes } from "../comment/comment-nodes"; import { Paginator } from "../common/paginator"; @@ -89,7 +89,8 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { return ( <CommentNodes key={i.id} - nodes={[{ comment_view: c }]} + nodes={[{ comment_view: c, children: [], depth: 0 }]} + viewType={CommentViewType.Flat} admins={Some(this.props.admins)} moderators={None} maxCommentsShown={None} @@ -159,6 +160,7 @@ export class PersonDetails extends Component<PersonDetailsProps, any> { <div> <CommentNodes nodes={commentsToFlatNodes(this.props.personRes.comments)} + viewType={CommentViewType.Flat} admins={Some(this.props.admins)} moderators={None} maxCommentsShown={None} diff --git a/src/shared/components/post/post-listing.tsx b/src/shared/components/post/post-listing.tsx index cbe84d9..672d3d6 100644 --- a/src/shared/components/post/post-listing.tsx +++ b/src/shared/components/post/post-listing.tsx @@ -408,7 +408,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { className={`btn-animate btn btn-link p-0 ${ this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted" }`} - onClick={linkEvent(this, this.handlePostLike)} + onClick={this.handlePostLike} data-tippy-content={i18n.t("upvote")} aria-label={i18n.t("upvote")} > @@ -431,7 +431,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { ? "text-danger" : "text-muted" }`} - onClick={linkEvent(this, this.handlePostDisLike)} + onClick={this.handlePostDisLike} data-tippy-content={i18n.t("downvote")} aria-label={i18n.t("downvote")} > @@ -647,7 +647,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted" }`} {...tippy} - onClick={linkEvent(this, this.handlePostLike)} + onClick={this.handlePostLike} aria-label={i18n.t("upvote")} > <Icon icon="arrow-up1" classes="icon-inline small" /> @@ -662,7 +662,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { ? "text-danger" : "text-muted" }`} - onClick={linkEvent(this, this.handlePostDisLike)} + onClick={this.handlePostDisLike} {...tippy} aria-label={i18n.t("downvote")} > @@ -1250,7 +1250,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> { }); } - handlePostLike(i: PostListing, event: any) { + handlePostLike(event: any) { event.preventDefault(); if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); @@ -1260,31 +1260,31 @@ export class PostListing extends Component<PostListingProps, PostListingState> { let newVote = myVote == 1 ? 0 : 1; if (myVote == 1) { - i.state.score--; - i.state.upvotes--; + this.state.score--; + this.state.upvotes--; } else if (myVote == -1) { - i.state.downvotes--; - i.state.upvotes++; - i.state.score += 2; + this.state.downvotes--; + this.state.upvotes++; + this.state.score += 2; } else { - i.state.upvotes++; - i.state.score++; + this.state.upvotes++; + this.state.score++; } - i.state.my_vote = Some(newVote); + this.state.my_vote = Some(newVote); let form = new CreatePostLike({ - post_id: i.props.post_view.post.id, + post_id: this.props.post_view.post.id, score: newVote, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.likePost(form)); - i.setState(i.state); + this.setState(this.state); setupTippy(); } - handlePostDisLike(i: PostListing, event: any) { + handlePostDisLike(event: any) { event.preventDefault(); if (UserService.Instance.myUserInfo.isNone()) { this.context.router.history.push(`/login`); @@ -1294,27 +1294,27 @@ export class PostListing extends Component<PostListingProps, PostListingState> { let newVote = myVote == -1 ? 0 : -1; if (myVote == 1) { - i.state.score -= 2; - i.state.upvotes--; - i.state.downvotes++; + this.state.score -= 2; + this.state.upvotes--; + this.state.downvotes++; } else if (myVote == -1) { - i.state.downvotes--; - i.state.score++; + this.state.downvotes--; + this.state.score++; } else { - i.state.downvotes++; - i.state.score--; + this.state.downvotes++; + this.state.score--; } - i.state.my_vote = Some(newVote); + this.state.my_vote = Some(newVote); let form = new CreatePostLike({ - post_id: i.props.post_view.post.id, + post_id: this.props.post_view.post.id, score: newVote, auth: auth().unwrap(), }); WebSocketService.Instance.send(wsClient.likePost(form)); - i.setState(i.state); + this.setState(this.state); setupTippy(); } diff --git a/src/shared/components/post/post.tsx b/src/shared/components/post/post.tsx index 9702c3d..e8b8619 100644 --- a/src/shared/components/post/post.tsx +++ b/src/shared/components/post/post.tsx @@ -7,15 +7,18 @@ import { BanFromCommunityResponse, BanPersonResponse, BlockPersonResponse, + CommentNode as CommentNodeI, CommentReportResponse, CommentResponse, + CommentSortType, CommunityResponse, + GetComments, + GetCommentsResponse, GetCommunityResponse, GetPost, GetPostResponse, GetSiteResponse, ListingType, - MarkCommentAsRead, PostReportResponse, PostResponse, PostView, @@ -30,17 +33,13 @@ import { } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { - CommentNode as CommentNodeI, - CommentSortType, - CommentViewType, - InitialFetchRequest, -} from "../../interfaces"; +import { CommentViewType, InitialFetchRequest } from "../../interfaces"; import { UserService, WebSocketService } from "../../services"; import { auth, buildCommentsTree, commentsToFlatNodes, + commentTreeMaxDepth, createCommentLikeRes, createPostLikeRes, debounce, @@ -48,6 +47,8 @@ import { enableDownvotes, enableNsfw, getCommentIdFromProps, + getCommentParentId, + getDepthFromComment, getIdFromProps, insertCommentIntoTree, isBrowser, @@ -73,10 +74,11 @@ import { PostListing } from "./post-listing"; const commentsShownInterval = 15; interface PostState { + postId: Option<number>; + commentId: Option<number>; postRes: Option<GetPostResponse>; - postId: number; + commentsRes: Option<GetCommentsResponse>; commentTree: CommentNodeI[]; - commentId?: number; commentSort: CommentSortType; commentViewType: CommentViewType; scrolled?: boolean; @@ -90,14 +92,19 @@ interface PostState { export class Post extends Component<any, PostState> { private subscription: Subscription; - private isoData = setIsoData(this.context, GetPostResponse); + private isoData = setIsoData( + this.context, + GetPostResponse, + GetCommentsResponse + ); private commentScrollDebounced: () => void; private emptyState: PostState = { postRes: None, + commentsRes: None, postId: getIdFromProps(this.props), - commentTree: [], commentId: getCommentIdFromProps(this.props), - commentSort: CommentSortType.Hot, + commentTree: [], + commentSort: CommentSortType[CommentSortType.Hot], commentViewType: CommentViewType.Tree, scrolled: false, loading: true, @@ -120,10 +127,19 @@ export class Post extends Component<any, PostState> { // Only fetch the data if coming from another route if (this.isoData.path == this.context.router.route.match.url) { this.state.postRes = Some(this.isoData.routeData[0] as GetPostResponse); - this.state.commentTree = buildCommentsTree( - this.state.postRes.unwrap().comments, - this.state.commentSort + this.state.commentsRes = Some( + this.isoData.routeData[1] as GetCommentsResponse ); + + this.state.commentsRes.match({ + some: res => { + this.state.commentTree = buildCommentsTree( + res.comments, + this.state.commentId.isSome() + ); + }, + none: void 0, + }); this.state.loading = false; if (isBrowser()) { @@ -133,14 +149,14 @@ export class Post extends Component<any, PostState> { this.state.postRes.unwrap().community_view.community.id, }) ); - WebSocketService.Instance.send( - wsClient.postJoin({ post_id: this.state.postId }) - ); + + this.state.postId.match({ + some: post_id => + WebSocketService.Instance.send(wsClient.postJoin({ post_id })), + none: void 0, + }); this.fetchCrossPosts(); - if (this.state.commentId) { - this.scrollCommentIntoView(); - } if (this.checkScrollIntoCommentsParam) { this.scrollIntoCommentSection(); @@ -152,11 +168,28 @@ export class Post extends Component<any, PostState> { } fetchPost() { - let form = new GetPost({ + 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.getPost(form)); + WebSocketService.Instance.send(wsClient.getComments(commentsForm)); } fetchCrossPosts() { @@ -184,15 +217,44 @@ export class Post extends Component<any, PostState> { static fetchInitialData(req: InitialFetchRequest): Promise<any>[] { let pathSplit = req.path.split("/"); + let promises: Promise<any>[] = []; + let pathType = pathSplit[1]; let id = Number(pathSplit[2]); let postForm = new GetPost({ - id, + 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, }); - return [req.client.getPost(postForm)]; + // 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() { @@ -222,18 +284,6 @@ export class Post extends Component<any, PostState> { } } - scrollCommentIntoView() { - let commentElement = document.getElementById( - `comment-${this.state.commentId}` - ); - if (commentElement) { - commentElement.scrollIntoView(); - commentElement.classList.add("mark"); - this.state.scrolled = true; - this.markScrolledAsRead(this.state.commentId); - } - } - get checkScrollIntoCommentsParam() { return Boolean( new URLSearchParams(this.props.location.search).get("scrollToComments") @@ -244,39 +294,6 @@ export class Post extends Component<any, PostState> { this.state.commentSectionRef.current?.scrollIntoView(); } - // TODO this needs some re-work - markScrolledAsRead(commentId: number) { - this.state.postRes.match({ - some: res => { - let found = res.comments.find(c => c.comment.id == commentId); - let parent = res.comments.find( - c => found.comment.parent_id.unwrapOr(0) == c.comment.id - ); - let parent_person_id = parent - ? parent.creator.id - : res.post_view.creator.id; - - UserService.Instance.myUserInfo.match({ - some: mui => { - if (mui.local_user_view.person.id == parent_person_id) { - let form = new MarkCommentAsRead({ - comment_id: found.comment.id, - read: true, - auth: auth().unwrap(), - }); - WebSocketService.Instance.send(wsClient.markCommentAsRead(form)); - UserService.Instance.unreadInboxCountSub.next( - UserService.Instance.unreadInboxCountSub.value - 1 - ); - } - }, - none: void 0, - }); - }, - none: void 0, - }); - } - isBottom(el: Element): boolean { return el?.getBoundingClientRect().bottom <= window.innerHeight; } @@ -351,7 +368,7 @@ export class Post extends Component<any, PostState> { /> <div ref={this.state.commentSectionRef} className="mb-2" /> <CommentForm - node={Right(this.state.postId)} + node={Right(res.post_view.post.id)} disabled={res.post_view.post.locked} /> <div class="d-block d-md-none"> @@ -371,10 +388,10 @@ export class Post extends Component<any, PostState> { </button> {this.state.showSidebarMobile && this.sidebar()} </div> - {res.comments.length > 0 && this.sortRadios()} + {this.sortRadios()} {this.state.commentViewType == CommentViewType.Tree && this.commentsTree()} - {this.state.commentViewType == CommentViewType.Chat && + {this.state.commentViewType == CommentViewType.Flat && this.commentsFlat()} </div> <div class="d-none d-md-block col-md-4">{this.sidebar()}</div> @@ -393,7 +410,8 @@ export class Post extends Component<any, PostState> { <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2"> <label className={`btn btn-outline-secondary pointer ${ - this.state.commentSort === CommentSortType.Hot && "active" + CommentSortType[this.state.commentSort] === CommentSortType.Hot && + "active" }`} > {i18n.t("hot")} @@ -406,7 +424,8 @@ export class Post extends Component<any, PostState> { </label> <label className={`btn btn-outline-secondary pointer ${ - this.state.commentSort === CommentSortType.Top && "active" + CommentSortType[this.state.commentSort] === CommentSortType.Top && + "active" }`} > {i18n.t("top")} @@ -419,7 +438,8 @@ export class Post extends Component<any, PostState> { </label> <label className={`btn btn-outline-secondary pointer ${ - this.state.commentSort === CommentSortType.New && "active" + CommentSortType[this.state.commentSort] === CommentSortType.New && + "active" }`} > {i18n.t("new")} @@ -432,7 +452,8 @@ export class Post extends Component<any, PostState> { </label> <label className={`btn btn-outline-secondary pointer ${ - this.state.commentSort === CommentSortType.Old && "active" + CommentSortType[this.state.commentSort] === CommentSortType.Old && + "active" }`} > {i18n.t("old")} @@ -447,14 +468,14 @@ export class Post extends Component<any, PostState> { <div class="btn-group btn-group-toggle flex-wrap mb-2"> <label className={`btn btn-outline-secondary pointer ${ - this.state.commentViewType === CommentViewType.Chat && "active" + this.state.commentViewType === CommentViewType.Flat && "active" }`} > {i18n.t("chat")} <input type="radio" - value={CommentViewType.Chat} - checked={this.state.commentViewType === CommentViewType.Chat} + value={CommentViewType.Flat} + checked={this.state.commentViewType === CommentViewType.Flat} onChange={linkEvent(this, this.handleCommentViewTypeChange)} /> </label> @@ -465,21 +486,26 @@ export class Post extends Component<any, PostState> { commentsFlat() { // These are already sorted by new - return this.state.postRes.match({ - some: res => ( - <div> - <CommentNodes - nodes={commentsToFlatNodes(res.comments)} - maxCommentsShown={Some(this.state.maxCommentsShown)} - noIndent - locked={res.post_view.post.locked} - moderators={Some(res.moderators)} - admins={Some(this.state.siteRes.admins)} - enableDownvotes={enableDownvotes(this.state.siteRes)} - showContext - /> - </div> - ), + return this.state.commentsRes.match({ + some: commentsRes => + this.state.postRes.match({ + some: postRes => ( + <div> + <CommentNodes + nodes={commentsToFlatNodes(commentsRes.comments)} + viewType={this.state.commentViewType} + maxCommentsShown={Some(this.state.maxCommentsShown)} + noIndent + locked={postRes.post_view.post.locked} + moderators={Some(postRes.moderators)} + admins={Some(this.state.siteRes.admins)} + enableDownvotes={enableDownvotes(this.state.siteRes)} + showContext + /> + </div> + ), + none: <></>, + }), none: <></>, }); } @@ -503,21 +529,18 @@ export class Post extends Component<any, PostState> { } handleCommentSortChange(i: Post, event: any) { - i.state.commentSort = Number(event.target.value); + i.state.commentSort = CommentSortType[event.target.value]; i.state.commentViewType = CommentViewType.Tree; - i.state.commentTree = buildCommentsTree( - i.state.postRes.map(r => r.comments).unwrapOr([]), - i.state.commentSort - ); i.setState(i.state); + i.fetchPost(); } handleCommentViewTypeChange(i: Post, event: any) { i.state.commentViewType = Number(event.target.value); i.state.commentSort = CommentSortType.New; i.state.commentTree = buildCommentsTree( - i.state.postRes.map(r => r.comments).unwrapOr([]), - i.state.commentSort + i.state.commentsRes.map(r => r.comments).unwrapOr([]), + i.state.commentId.isSome() ); i.setState(i.state); } @@ -527,12 +550,52 @@ export class Post extends Component<any, PostState> { i.setState(i.state); } + 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 = + getDepthFromComment(this.state.commentTree[0].comment_view.comment) > 0; + return this.state.postRes.match({ some: res => ( <div> + {this.state.commentId.isSome() && ( + <> + <button + class="pl-0 d-block btn btn-link text-muted" + onClick={linkEvent(this, this.handleViewPost)} + > + {i18n.t("view_all_comments")} â + </button> + {showContextButton && ( + <button + class="pl-0 d-block btn btn-link text-muted" + onClick={linkEvent(this, this.handleViewContext)} + > + {i18n.t("show_context")} â + </button> + )} + </> + )} <CommentNodes nodes={this.state.commentTree} + viewType={this.state.commentViewType} maxCommentsShown={Some(this.state.maxCommentsShown)} locked={res.post_view.post.locked} moderators={Some(res.moderators)} @@ -552,27 +615,29 @@ export class Post extends Component<any, PostState> { toast(i18n.t(msg.error), "danger"); return; } else if (msg.reconnect) { - let postId = Number(this.props.match.params.id); - WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId })); - WebSocketService.Instance.send( - wsClient.getPost({ - id: postId, - auth: auth(false).ok(), - }) - ); + 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<GetPostResponse>(msg, GetPostResponse); this.state.postRes = Some(data); - this.state.commentTree = buildCommentsTree( - this.state.postRes.map(r => r.comments).unwrapOr([]), - this.state.commentSort - ); - this.state.loading = false; - // join the rooms WebSocketService.Instance.send( - wsClient.postJoin({ post_id: this.state.postId }) + wsClient.postJoin({ post_id: data.post_view.post.id }) ); WebSocketService.Instance.send( wsClient.communityJoin({ @@ -581,18 +646,36 @@ export class Post extends Component<any, PostState> { ); // Get cross-posts + // TODO move this into initial fetch and refetch this.fetchCrossPosts(); this.setState(this.state); setupTippy(); - if (!this.state.commentId) restoreScrollPosition(this.context); + if (this.state.commentId.isNone()) restoreScrollPosition(this.context); if (this.checkScrollIntoCommentsParam) { this.scrollIntoCommentSection(); } - - if (this.state.commentId && !this.state.scrolled) { - this.scrollCommentIntoView(); - } + } else if (op == UserOperation.GetComments) { + let data = wsJsonToRes<GetCommentsResponse>(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.state.commentsRes = Some(data); + }, + }); + // this.state.commentsRes = Some(data); + this.state.commentTree = buildCommentsTree( + this.state.commentsRes.map(r => r.comments).unwrapOr([]), + this.state.commentId.isSome() + ); + this.state.loading = false; + this.setState(this.state); } else if (op == UserOperation.CreateComment) { let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); @@ -606,11 +689,19 @@ export class Post extends Component<any, PostState> { // 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: res => { - res.comments.unshift(data.comment_view); - insertCommentIntoTree(this.state.commentTree, data.comment_view); - res.post_view.counts.comments++; - }, + 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); @@ -624,14 +715,14 @@ export class Post extends Component<any, PostState> { let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); editCommentRes( data.comment_view, - this.state.postRes.map(r => r.comments).unwrapOr([]) + this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); } else if (op == UserOperation.SaveComment) { let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); saveCommentRes( data.comment_view, - this.state.postRes.map(r => r.comments).unwrapOr([]) + this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); setupTippy(); @@ -639,7 +730,7 @@ export class Post extends Component<any, PostState> { let data = wsJsonToRes<CommentResponse>(msg, CommentResponse); createCommentLikeRes( data.comment_view, - this.state.postRes.map(r => r.comments).unwrapOr([]) + this.state.commentsRes.map(r => r.comments).unwrapOr([]) ); this.setState(this.state); } else if (op == UserOperation.CreatePostLike) { @@ -685,15 +776,19 @@ export class Post extends Component<any, PostState> { BanFromCommunityResponse ); this.state.postRes.match({ - some: res => { - res.comments - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator_banned_from_community = data.banned)); - if (res.post_view.creator.id == data.person_view.person.id) { - res.post_view.creator_banned_from_community = data.banned; - } - this.setState(this.state); - }, + 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) { @@ -711,15 +806,19 @@ export class Post extends Component<any, PostState> { } else if (op == UserOperation.BanPerson) { let data = wsJsonToRes<BanPersonResponse>(msg, BanPersonResponse); this.state.postRes.match({ - some: res => { - res.comments - .filter(c => c.creator.id == data.person_view.person.id) - .forEach(c => (c.creator.banned = data.banned)); - if (res.post_view.creator.id == data.person_view.person.id) { - res.post_view.creator.banned = data.banned; - } - this.setState(this.state); - }, + 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) { diff --git a/src/shared/components/search.tsx b/src/shared/components/search.tsx index 1306cae..c54b2d2 100644 --- a/src/shared/components/search.tsx +++ b/src/shared/components/search.tsx @@ -26,8 +26,8 @@ import { wsUserOp, } from "lemmy-js-client"; import { Subscription } from "rxjs"; -import { InitialFetchRequest } from "shared/interfaces"; import { i18n } from "../i18next"; +import { CommentViewType, InitialFetchRequest } from "../interfaces"; import { WebSocketService } from "../services"; import { auth, @@ -594,7 +594,14 @@ export class Search extends Component<any, SearchState> { {i.type_ == "comments" && ( <CommentNodes key={(i.data as CommentView).comment.id} - nodes={[{ comment_view: i.data as CommentView }]} + nodes={[ + { + comment_view: i.data as CommentView, + children: [], + depth: 0, + }, + ]} + viewType={CommentViewType.Flat} moderators={None} admins={None} maxCommentsShown={None} @@ -631,6 +638,7 @@ export class Search extends Component<any, SearchState> { return ( <CommentNodes nodes={commentsToFlatNodes(comments)} + viewType={CommentViewType.Flat} locked noIndent moderators={None} diff --git a/src/shared/interfaces.ts b/src/shared/interfaces.ts index 0a157b5..c25f5b1 100644 --- a/src/shared/interfaces.ts +++ b/src/shared/interfaces.ts @@ -1,10 +1,5 @@ import { Either, Option } from "@sniptt/monads"; -import { - CommentView, - GetSiteResponse, - LemmyHttp, - PersonMentionView, -} from "lemmy-js-client"; +import { GetSiteResponse, LemmyHttp } from "lemmy-js-client"; /** * This contains serialized data, it needs to be deserialized before use. @@ -32,12 +27,6 @@ export interface InitialFetchRequest { path: string; } -export interface CommentNode { - comment_view: CommentView | PersonMentionView; - children?: CommentNode[]; - depth?: number; -} - export interface PostFormParams { name: Option<string>; url: Option<string>; @@ -45,16 +34,9 @@ export interface PostFormParams { nameOrId: Option<Either<string, number>>; } -export enum CommentSortType { - Hot, - Top, - New, - Old, -} - export enum CommentViewType { Tree, - Chat, + Flat, } export enum DataType { diff --git a/src/shared/routes.ts b/src/shared/routes.ts index 8035471..336503f 100644 --- a/src/shared/routes.ts +++ b/src/shared/routes.ts @@ -72,12 +72,12 @@ export const routes: IRoutePropsWithFetch[] = [ fetchInitialData: req => Communities.fetchInitialData(req), }, { - path: `/post/:id/comment/:comment_id`, + path: `/post/:post_id`, component: Post, fetchInitialData: req => Post.fetchInitialData(req), }, { - path: `/post/:id`, + path: `/comment/:comment_id`, component: Post, fetchInitialData: req => Post.fetchInitialData(req), }, diff --git a/src/shared/utils.ts b/src/shared/utils.ts index a911621..b19ee22 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -4,7 +4,10 @@ import emojiShortName from "emoji-short-name"; import { BlockCommunityResponse, BlockPersonResponse, + Comment as CommentI, + CommentNode as CommentNodeI, CommentReportView, + CommentSortType, CommentView, CommunityBlockView, CommunityModeratorView, @@ -39,12 +42,7 @@ import tippy from "tippy.js"; import Toastify from "toastify-js"; import { httpBase } from "./env"; import { i18n, languages } from "./i18next"; -import { - CommentNode as CommentNodeI, - CommentSortType, - DataType, - IsoData, -} from "./interfaces"; +import { DataType, IsoData } from "./interfaces"; import { UserService, WebSocketService } from "./services"; var Tribute: any; @@ -74,6 +72,7 @@ export const postRefetchSeconds: number = 60 * 1000; export const fetchLimit = 20; export const trendingFetchLimit = 6; export const mentionDropdownFetchLimit = 10; +export const commentTreeMaxDepth = 8; export const relTags = "noopener nofollow"; @@ -611,7 +610,7 @@ export function notifyComment(comment_view: CommentView, router: any) { let info: NotifyInfo = { name: comment_view.creator.name, icon: comment_view.creator.avatar, - link: `/post/${comment_view.post.id}/comment/${comment_view.comment.id}`, + link: `/comment/${comment_view.comment.id}`, body: comment_view.comment.content, }; notify(info, router); @@ -813,12 +812,14 @@ export function getRecipientIdFromProps(props: any): number { : 1; } -export function getIdFromProps(props: any): number { - return Number(props.match.params.id); +export function getIdFromProps(props: any): Option<number> { + let id: string = props.match.params.post_id; + return id ? Some(Number(id)) : None; } -export function getCommentIdFromProps(props: any): number { - return Number(props.match.params.comment_id); +export function getCommentIdFromProps(props: any): Option<number> { + let id: string = props.match.params.comment_id; + return id ? Some(Number(id)) : None; } export function getUsernameFromProps(props: any): string { @@ -985,61 +986,12 @@ export function updateRegistrationApplicationRes( export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] { let nodes: CommentNodeI[] = []; for (let comment of comments) { - nodes.push({ comment_view: comment }); + nodes.push({ comment_view: comment, children: [], depth: 0 }); } return nodes; } -function commentSort(tree: CommentNodeI[], sort: CommentSortType) { - // First, put removed and deleted comments at the bottom, then do your other sorts - if (sort == CommentSortType.Top) { - tree.sort( - (a, b) => - +a.comment_view.comment.removed - +b.comment_view.comment.removed || - +a.comment_view.comment.deleted - +b.comment_view.comment.deleted || - b.comment_view.counts.score - a.comment_view.counts.score - ); - } else if (sort == CommentSortType.New) { - tree.sort( - (a, b) => - +a.comment_view.comment.removed - +b.comment_view.comment.removed || - +a.comment_view.comment.deleted - +b.comment_view.comment.deleted || - b.comment_view.comment.published.localeCompare( - a.comment_view.comment.published - ) - ); - } else if (sort == CommentSortType.Old) { - tree.sort( - (a, b) => - +a.comment_view.comment.removed - +b.comment_view.comment.removed || - +a.comment_view.comment.deleted - +b.comment_view.comment.deleted || - a.comment_view.comment.published.localeCompare( - b.comment_view.comment.published - ) - ); - } else if (sort == CommentSortType.Hot) { - tree.sort( - (a, b) => - +a.comment_view.comment.removed - +b.comment_view.comment.removed || - +a.comment_view.comment.deleted - +b.comment_view.comment.deleted || - hotRankComment(b.comment_view as CommentView) - - hotRankComment(a.comment_view as CommentView) - ); - } - - // Go through the children recursively - for (let node of tree) { - if (node.children) { - commentSort(node.children, sort); - } - } -} - -export function commentSortSortType(tree: CommentNodeI[], sort: SortType) { - commentSort(tree, convertCommentSortType(sort)); -} - -function convertCommentSortType(sort: SortType): CommentSortType { +export function convertCommentSortType(sort: SortType): CommentSortType { if ( sort == SortType.TopAll || sort == SortType.TopDay || @@ -1059,21 +1011,32 @@ function convertCommentSortType(sort: SortType): CommentSortType { export function buildCommentsTree( comments: CommentView[], - commentSortType: CommentSortType + parentComment: boolean ): CommentNodeI[] { let map = new Map<number, CommentNodeI>(); + let depthOffset = !parentComment + ? 0 + : getDepthFromComment(comments[0].comment); + for (let comment_view of comments) { let node: CommentNodeI = { comment_view: comment_view, children: [], - depth: 0, + depth: getDepthFromComment(comment_view.comment) - depthOffset, }; map.set(comment_view.comment.id, { ...node }); } + let tree: CommentNodeI[] = []; + + // if its a parent comment fetch, then push the first comment to the top node. + if (parentComment) { + tree.push(map.get(comments[0].comment.id)); + } + for (let comment_view of comments) { let child = map.get(comment_view.comment.id); - let parent_id = comment_view.comment.parent_id; + let parent_id = getCommentParentId(comment_view.comment); parent_id.match({ some: parentId => { let parent = map.get(parentId); @@ -1083,26 +1046,37 @@ export function buildCommentsTree( } }, none: () => { - tree.push(child); + if (!parentComment) { + tree.push(child); + } }, }); - - setDepth(child); } - commentSort(tree, commentSortType); - return tree; } -function setDepth(node: CommentNodeI, i = 0) { - for (let child of node.children) { - child.depth = i; - setDepth(child, i + 1); +export function getCommentParentId(comment: CommentI): Option<number> { + let split = comment.path.split("."); + // remove the 0 + split.shift(); + + if (split.length > 1) { + return Some(Number(split[split.length - 2])); + } else { + return None; } } -export function insertCommentIntoTree(tree: CommentNodeI[], cv: CommentView) { +export function getDepthFromComment(comment: CommentI): number { + return comment.path.split(".").length - 2; +} + +export function insertCommentIntoTree( + tree: CommentNodeI[], + cv: CommentView, + parentComment: boolean +) { // Building a fake node to be used for later let node: CommentNodeI = { comment_view: cv, @@ -1110,7 +1084,7 @@ export function insertCommentIntoTree(tree: CommentNodeI[], cv: CommentView) { depth: 0, }; - cv.comment.parent_id.match({ + getCommentParentId(cv.comment).match({ some: parentId => { let parentComment = searchCommentTree(tree, parentId); parentComment.match({ @@ -1122,7 +1096,9 @@ export function insertCommentIntoTree(tree: CommentNodeI[], cv: CommentView) { }); }, none: () => { - tree.unshift(node); + if (!parentComment) { + tree.unshift(node); + } }, }); } @@ -1149,6 +1125,7 @@ export function searchCommentTree( export const colorList: string[] = [ hsl(0), + hsl(50), hsl(100), hsl(150), hsl(200), @@ -1439,3 +1416,15 @@ export function enableDownvotes(siteRes: GetSiteResponse): boolean { export function enableNsfw(siteRes: GetSiteResponse): boolean { return siteRes.site_view.map(s => s.site.enable_nsfw).unwrapOr(false); } + +export function postToCommentSortType(sort: SortType): CommentSortType { + if ([SortType.Active, SortType.Hot].includes(sort)) { + return CommentSortType.Hot; + } else if ([SortType.New, SortType.NewComments].includes(sort)) { + return CommentSortType.New; + } else if (sort == SortType.Old) { + return CommentSortType.Old; + } else { + return CommentSortType.Top; + } +} diff --git a/yarn.lock b/yarn.lock index 7e37d66..f972e11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5056,10 +5056,10 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" -lemmy-js-client@0.17.0-rc.38: - version "0.17.0-rc.38" - resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.0-rc.38.tgz#bbe0667ad44bbd0c1e516813d3c81b65c7f6a329" - integrity sha512-uDC19s3+Eva+Hu3LhIPkT5j0Ngh7F84TA4VnfxMVJN6BQZFLZUmTvAErwJcqyj5vz3sNnw4jsEeTSGPODSXfeg== +lemmy-js-client@0.17.0-rc.39: + version "0.17.0-rc.39" + resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.0-rc.39.tgz#b17c5c0d9a0f36c90c17be99845a5703091ab306" + integrity sha512-MsKavo5xOob6DgfjyhbmXyFvXwdW4iwftStJ7Bz3ArlHXy6zGBp+2uy2rU2c5ujivNDX72ol3TupTHBtSXLs4w== levn@^0.4.1: version "0.4.1" -- 2.44.1