From 7c13b8dba16452d777dc96494ef90349bc35bf8c Mon Sep 17 00:00:00 2001
From: Jay Sitter <jay@jaysitter.com>
Date: Thu, 22 Jun 2023 13:36:38 -0400
Subject: [PATCH] feat: Move vote buttons to separate component

---
 src/shared/components/common/vote-buttons.tsx | 207 ++++++++++++++++++
 src/shared/components/post/post-listing.tsx   | 163 ++++----------
 2 files changed, 244 insertions(+), 126 deletions(-)
 create mode 100644 src/shared/components/common/vote-buttons.tsx

diff --git a/src/shared/components/common/vote-buttons.tsx b/src/shared/components/common/vote-buttons.tsx
new file mode 100644
index 0000000..2bbddad
--- /dev/null
+++ b/src/shared/components/common/vote-buttons.tsx
@@ -0,0 +1,207 @@
+import { showScores } from "@utils/app";
+import { numToSI } from "@utils/helpers";
+import { Component, linkEvent } from "inferno";
+import { CommentAggregates, PostAggregates } from "lemmy-js-client";
+import { I18NextService } from "../../services";
+import { Icon, Spinner } from "../common/icon";
+import { PostListing } from "../post/post-listing";
+
+interface VoteButtonsProps {
+  postListing: PostListing;
+  enableDownvotes?: boolean;
+  upvoteLoading?: boolean;
+  downvoteLoading?: boolean;
+  handleUpvote: (i: PostListing) => void;
+  handleDownvote: (i: PostListing) => void;
+  counts: CommentAggregates | PostAggregates;
+  my_vote?: number;
+}
+
+interface VoteButtonsState {
+  upvoteLoading: boolean;
+  downvoteLoading: boolean;
+}
+
+export class VoteButtonsCompact extends Component<
+  VoteButtonsProps,
+  VoteButtonsState
+> {
+  state: VoteButtonsState = {
+    upvoteLoading: false,
+    downvoteLoading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  get pointsTippy(): string {
+    const points = I18NextService.i18n.t("number_of_points", {
+      count: Number(this.props.counts.score),
+      formattedCount: Number(this.props.counts.score),
+    });
+
+    const upvotes = I18NextService.i18n.t("number_of_upvotes", {
+      count: Number(this.props.counts.upvotes),
+      formattedCount: Number(this.props.counts.upvotes),
+    });
+
+    const downvotes = I18NextService.i18n.t("number_of_downvotes", {
+      count: Number(this.props.counts.downvotes),
+      formattedCount: Number(this.props.counts.downvotes),
+    });
+
+    return `${points} • ${upvotes} • ${downvotes}`;
+  }
+
+  get tippy() {
+    return showScores() ? { "data-tippy-content": this.pointsTippy } : {};
+  }
+
+  render() {
+    return (
+      <>
+        <div className="input-group input-group-sm w-auto">
+          <button
+            className={`btn btn-sm btn-animate btn-outline-primary rounded-start py-0 ${
+              this.props.my_vote === 1 ? "text-info" : "text-muted"
+            }`}
+            {...this.tippy}
+            onClick={linkEvent(this.props.postListing, this.props.handleUpvote)}
+            aria-label={I18NextService.i18n.t("upvote")}
+            aria-pressed={this.props.my_vote === 1}
+          >
+            {this.state.upvoteLoading ? (
+              <Spinner />
+            ) : (
+              <>
+                <Icon icon="arrow-up1" classes="icon-inline small" />
+                {showScores() && (
+                  <span className="ms-2">
+                    {numToSI(this.props.counts.upvotes)}
+                  </span>
+                )}
+              </>
+            )}
+          </button>
+          <span className="input-group-text small py-0">
+            {numToSI(this.props.counts.score)}
+          </span>
+          {this.props.enableDownvotes && (
+            <button
+              className={`btn btn-sm btn-animate btn-outline-primary rounded-end py-0 ${
+                this.props.my_vote === -1 ? "text-danger" : "text-muted"
+              }`}
+              onClick={linkEvent(
+                this.props.postListing,
+                this.props.handleDownvote
+              )}
+              {...this.tippy}
+              aria-label={I18NextService.i18n.t("downvote")}
+              aria-pressed={this.props.my_vote === -1}
+            >
+              {this.state.downvoteLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  <Icon icon="arrow-down1" classes="icon-inline small" />
+                  {showScores() && (
+                    <span className="ms-2">
+                      {numToSI(this.props.counts.downvotes)}
+                    </span>
+                  )}
+                </>
+              )}
+            </button>
+          )}
+        </div>
+      </>
+    );
+  }
+}
+
+export class VoteButtons extends Component<VotesProps, VotesState> {
+  state: VotesState = {
+    upvoteLoading: false,
+    downvoteLoading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  get pointsTippy(): string {
+    const points = I18NextService.i18n.t("number_of_points", {
+      count: Number(this.props.counts.score),
+      formattedCount: Number(this.props.counts.score),
+    });
+
+    const upvotes = I18NextService.i18n.t("number_of_upvotes", {
+      count: Number(this.props.counts.upvotes),
+      formattedCount: Number(this.props.counts.upvotes),
+    });
+
+    const downvotes = I18NextService.i18n.t("number_of_downvotes", {
+      count: Number(this.props.counts.downvotes),
+      formattedCount: Number(this.props.counts.downvotes),
+    });
+
+    return `${points} • ${upvotes} • ${downvotes}`;
+  }
+
+  get tippy() {
+    return showScores() ? { "data-tippy-content": this.pointsTippy } : {};
+  }
+
+  render() {
+    return (
+      <div className={`vote-bar col-1 pe-0 small text-center`}>
+        <button
+          className={`btn-animate btn btn-link p-0 ${
+            this.props.my_vote == 1 ? "text-info" : "text-muted"
+          }`}
+          onClick={linkEvent(this.props.postListing, this.props.handleUpvote)}
+          data-tippy-content={I18NextService.i18n.t("upvote")}
+          aria-label={I18NextService.i18n.t("upvote")}
+          aria-pressed={this.props.my_vote === 1}
+        >
+          {this.state.upvoteLoading ? (
+            <Spinner />
+          ) : (
+            <Icon icon="arrow-up1" classes="upvote" />
+          )}
+        </button>
+        {showScores() ? (
+          <div
+            className={`unselectable pointer text-muted px-1 post-score`}
+            data-tippy-content={this.pointsTippy}
+          >
+            {numToSI(this.props.counts.score)}
+          </div>
+        ) : (
+          <div className="p-1"></div>
+        )}
+        {this.props.enableDownvotes && (
+          <button
+            className={`btn-animate btn btn-link p-0 ${
+              this.props.my_vote == -1 ? "text-danger" : "text-muted"
+            }`}
+            onClick={linkEvent(
+              this.props.postListing,
+              this.props.handleDownvote
+            )}
+            data-tippy-content={I18NextService.i18n.t("downvote")}
+            aria-label={I18NextService.i18n.t("downvote")}
+            aria-pressed={this.props.my_vote === -1}
+          >
+            {this.state.downvoteLoading ? (
+              <Spinner />
+            ) : (
+              <Icon icon="arrow-down1" classes="downvote" />
+            )}
+          </button>
+        )}
+      </div>
+    );
+  }
+}
diff --git a/src/shared/components/post/post-listing.tsx b/src/shared/components/post/post-listing.tsx
index 4d0951b..7eed489 100644
--- a/src/shared/components/post/post-listing.tsx
+++ b/src/shared/components/post/post-listing.tsx
@@ -1,11 +1,10 @@
-import { myAuthRequired, newVote, showScores } from "@utils/app";
+import { myAuthRequired, newVote } from "@utils/app";
 import { canShare, share } from "@utils/browser";
 import { getExternalHost, getHttpBase } from "@utils/env";
 import {
   capitalizeFirstLetter,
   futureDaysToUnixTime,
   hostname,
-  numToSI,
 } from "@utils/helpers";
 import { isImage, isVideo } from "@utils/media";
 import {
@@ -51,6 +50,7 @@ import { setupTippy } from "../../tippy";
 import { Icon, PurgeWarning, Spinner } from "../common/icon";
 import { MomentTime } from "../common/moment-time";
 import { PictrsImage } from "../common/pictrs-image";
+import { VoteButtons, VoteButtonsCompact } from "../common/vote-buttons";
 import { CommunityLink } from "../community/community-link";
 import { PersonListing } from "../person/person-listing";
 import { MetadataCard } from "./metadata-card";
@@ -413,55 +413,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     );
   }
 
-  voteBar() {
-    return (
-      <div className={`vote-bar col-1 pe-0 small text-center`}>
-        <button
-          className={`btn-animate btn btn-link p-0 ${
-            this.postView.my_vote == 1 ? "text-info" : "text-muted"
-          }`}
-          onClick={linkEvent(this, this.handleUpvote)}
-          data-tippy-content={I18NextService.i18n.t("upvote")}
-          aria-label={I18NextService.i18n.t("upvote")}
-          aria-pressed={this.postView.my_vote === 1}
-        >
-          {this.state.upvoteLoading ? (
-            <Spinner />
-          ) : (
-            <Icon icon="arrow-up1" classes="upvote" />
-          )}
-        </button>
-        {showScores() ? (
-          <div
-            className={`unselectable pointer text-muted px-1 post-score`}
-            data-tippy-content={this.pointsTippy}
-          >
-            {numToSI(this.postView.counts.score)}
-          </div>
-        ) : (
-          <div className="p-1"></div>
-        )}
-        {this.props.enableDownvotes && (
-          <button
-            className={`btn-animate btn btn-link p-0 ${
-              this.postView.my_vote == -1 ? "text-danger" : "text-muted"
-            }`}
-            onClick={linkEvent(this, this.handleDownvote)}
-            data-tippy-content={I18NextService.i18n.t("downvote")}
-            aria-label={I18NextService.i18n.t("downvote")}
-            aria-pressed={this.postView.my_vote === -1}
-          >
-            {this.state.downvoteLoading ? (
-              <Spinner />
-            ) : (
-              <Icon icon="arrow-down1" classes="downvote" />
-            )}
-          </button>
-        )}
-      </div>
-    );
-  }
-
   get postLink() {
     const post = this.postView.post;
     return (
@@ -641,7 +592,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             <Icon icon="fedilink" inline />
           </a>
         )}
-        {mobile && !this.props.viewOnly && this.mobileVotes}
+        {mobile && !this.props.viewOnly && (
+          <VoteButtonsCompact
+            postListing={this}
+            enableDownvotes={this.props.enableDownvotes}
+            handleUpvote={this.handleUpvote}
+            handleDownvote={this.handleDownvote}
+            counts={this.postView.counts}
+            my_vote={this.postView.my_vote}
+          />
+        )}
         {UserService.Instance.myUserInfo &&
           !this.props.viewOnly &&
           this.postActions()}
@@ -679,7 +639,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     return (
       <>
         {this.saveButton}
-        {this.crossPostButton}
 
         {/**
          * If there is a URL, or if the post has a body and we were told not to
@@ -704,6 +663,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           </button>
 
           <ul className="dropdown-menu" id="advancedButtonsDropdown">
+            <li>{this.crossPostButton}</li>
+            <li>
+              <hr className="dropdown-divider" />
+            </li>
+
             {!this.myPost ? (
               <>
                 <li>{this.reportButton}</li>
@@ -770,69 +734,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
       : pv.unread_comments;
   }
 
-  get mobileVotes() {
-    // TODO: make nicer
-    const tippy = showScores()
-      ? { "data-tippy-content": this.pointsTippy }
-      : {};
-    return (
-      <>
-        <div>
-          <button
-            className={`btn-animate btn py-0 px-1 ${
-              this.postView.my_vote === 1 ? "text-info" : "text-muted"
-            }`}
-            {...tippy}
-            onClick={linkEvent(this, this.handleUpvote)}
-            aria-label={I18NextService.i18n.t("upvote")}
-            aria-pressed={this.postView.my_vote === 1}
-          >
-            {this.state.upvoteLoading ? (
-              <Spinner />
-            ) : (
-              <>
-                <Icon icon="arrow-up1" classes="icon-inline small" />
-                {showScores() && (
-                  <span className="ms-2">
-                    {numToSI(this.postView.counts.upvotes)}
-                  </span>
-                )}
-              </>
-            )}
-          </button>
-          {this.props.enableDownvotes && (
-            <button
-              className={`ms-2 btn-animate btn py-0 px-1 ${
-                this.postView.my_vote === -1 ? "text-danger" : "text-muted"
-              }`}
-              onClick={linkEvent(this, this.handleDownvote)}
-              {...tippy}
-              aria-label={I18NextService.i18n.t("downvote")}
-              aria-pressed={this.postView.my_vote === -1}
-            >
-              {this.state.downvoteLoading ? (
-                <Spinner />
-              ) : (
-                <>
-                  <Icon icon="arrow-down1" classes="icon-inline small" />
-                  {showScores() && (
-                    <span
-                      className={classNames("ms-2", {
-                        invisible: this.postView.counts.downvotes === 0,
-                      })}
-                    >
-                      {numToSI(this.postView.counts.downvotes)}
-                    </span>
-                  )}
-                </>
-              )}
-            </button>
-          )}
-        </div>
-      </>
-    );
-  }
-
   get saveButton() {
     const saved = this.postView.saved;
     const label = saved
@@ -861,7 +762,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   get crossPostButton() {
     return (
       <Link
-        className="btn btn-sm btn-animate text-muted py-0"
+        className="btn btn-sm d-flex align-items-center rounded-0 dropdown-item"
         to={{
           /* Empty string properties are required to satisfy type*/
           pathname: "/create_post",
@@ -874,7 +775,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         data-tippy-content={I18NextService.i18n.t("cross_post")}
         aria-label={I18NextService.i18n.t("cross_post")}
       >
-        <Icon icon="copy" inline />
+        <Icon classes="me-1" icon="copy" inline />
+        {I18NextService.i18n.t("cross_post")}
       </Link>
     );
   }
@@ -931,7 +833,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
       <button
         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(this, this.handleDeleteClick)}
-        aria-label={label}
       >
         {this.state.deleteLoading ? (
           <Spinner />
@@ -1434,7 +1335,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           {this.postTitleLine()}
         </div>
         <div className="col-4">
-          {/* Post body prev or thumbnail */}
+          {/* Post thumbnail */}
           {!this.state.imageExpanded && this.thumbnail()}
         </div>
       </div>
@@ -1443,7 +1344,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     );
   }
 
-  showMobilePreview() {
+  bodyPreview() {
     const { body, id } = this.postView.post;
 
     return !this.showBody && body ? (
@@ -1467,10 +1368,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               {/* If it has a thumbnail, do a right aligned thumbnail */}
               {this.mobileThumbnail()}
 
-              {/* Show a preview of the post body */}
-              {this.showMobilePreview()}
-
-              {this.commentsLine(true)}
+              <div className="mt-2">
+                {this.bodyPreview()}
+                {this.commentsLine(true)}
+              </div>
               {this.userActionsLine()}
               {this.duplicatesLine()}
               {this.removeAndBanDialogs()}
@@ -1481,7 +1382,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         {/* The larger view*/}
         <div className="d-none d-sm-block">
           <article className="row post-container">
-            {!this.props.viewOnly && this.voteBar()}
+            {!this.props.viewOnly && (
+              <VoteButtons
+                postListing={this}
+                enableDownvotes={this.props.enableDownvotes}
+                handleUpvote={this.handleUpvote}
+                handleDownvote={this.handleDownvote}
+                counts={this.postView.counts}
+                my_vote={this.postView.my_vote}
+              />
+            )}
             <div className="col-sm-2 pe-0 post-media">
               <div className="">{this.thumbnail()}</div>
             </div>
@@ -1490,6 +1400,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 <div className="col-12">
                   {this.postTitleLine()}
                   {this.createdLine()}
+                  {this.bodyPreview()}
                   {this.commentsLine()}
                   {this.duplicatesLine()}
                   {this.userActionsLine()}
-- 
2.44.1