]> Untitled Git - lemmy-ui.git/blobdiff - src/shared/components/post/post-listing.tsx
Merge branch 'main' into fix/add-aria-label-to-featured-pins
[lemmy-ui.git] / src / shared / components / post / post-listing.tsx
index 7eb289c81d1704399e24002dba78d64997e1118d..8e69b18ec428f7fea31228f5a73d1dbc18273da8 100644 (file)
@@ -11,6 +11,7 @@ import {
   CreatePostLike,
   CreatePostReport,
   DeletePost,
+  EditPost,
   FeaturePost,
   Language,
   LockPost,
@@ -24,8 +25,8 @@ import {
 } from "lemmy-js-client";
 import { getExternalHost, getHttpBase } from "../../env";
 import { i18n } from "../../i18next";
-import { BanType, PostFormParams, PurgeType } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { BanType, PostFormParams, PurgeType, VoteType } from "../../interfaces";
+import { UserService } from "../../services";
 import {
   amAdmin,
   amCommunityCreator,
@@ -43,13 +44,13 @@ import {
   mdNoImages,
   mdToHtml,
   mdToHtmlInline,
-  myAuth,
+  myAuthRequired,
+  newVote,
   numToSI,
   relTags,
   setupTippy,
   share,
   showScores,
-  wsClient,
 } from "../../utils";
 import { Icon, PurgeWarning, Spinner } from "../common/icon";
 import { MomentTime } from "../common/moment-time";
@@ -81,15 +82,25 @@ interface PostListingState {
   showBody: boolean;
   showReportDialog: boolean;
   reportReason?: string;
-  my_vote?: number;
-  score: number;
-  upvotes: number;
-  downvotes: number;
+  upvoteLoading: boolean;
+  downvoteLoading: boolean;
+  reportLoading: boolean;
+  blockLoading: boolean;
+  lockLoading: boolean;
+  deleteLoading: boolean;
+  removeLoading: boolean;
+  saveLoading: boolean;
+  featureCommunityLoading: boolean;
+  featureLocalLoading: boolean;
+  banLoading: boolean;
+  addModLoading: boolean;
+  addAdminLoading: boolean;
+  transferLoading: boolean;
 }
 
 interface PostListingProps {
   post_view: PostView;
-  duplicates?: PostView[];
+  crossPosts?: PostView[];
   moderators?: CommunityModeratorView[];
   admins?: PersonView[];
   allLanguages: Language[];
@@ -100,6 +111,22 @@ interface PostListingProps {
   enableDownvotes?: boolean;
   enableNsfw?: boolean;
   viewOnly?: boolean;
+  onPostEdit(form: EditPost): void;
+  onPostVote(form: CreatePostLike): void;
+  onPostReport(form: CreatePostReport): void;
+  onBlockPerson(form: BlockPerson): void;
+  onLockPost(form: LockPost): void;
+  onDeletePost(form: DeletePost): void;
+  onRemovePost(form: RemovePost): void;
+  onSavePost(form: SavePost): void;
+  onFeaturePost(form: FeaturePost): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgePost(form: PurgePost): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onTransferCommunity(form: TransferCommunity): void;
 }
 
 export class PostListing extends Component<PostListingProps, PostListingState> {
@@ -108,7 +135,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     showRemoveDialog: false,
     showPurgeDialog: false,
     purgeType: PurgeType.Person,
-    purgeLoading: false,
     showBanDialog: false,
     banType: BanType.Community,
     removeData: false,
@@ -120,86 +146,108 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     showMoreMobile: false,
     showBody: false,
     showReportDialog: false,
-    my_vote: this.props.post_view.my_vote,
-    score: this.props.post_view.counts.score,
-    upvotes: this.props.post_view.counts.upvotes,
-    downvotes: this.props.post_view.counts.downvotes,
+    upvoteLoading: false,
+    downvoteLoading: false,
+    purgeLoading: false,
+    reportLoading: false,
+    blockLoading: false,
+    lockLoading: false,
+    deleteLoading: false,
+    removeLoading: false,
+    saveLoading: false,
+    featureCommunityLoading: false,
+    featureLocalLoading: false,
+    banLoading: false,
+    addModLoading: false,
+    addAdminLoading: false,
+    transferLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.handlePostLike = this.handlePostLike.bind(this);
-    this.handlePostDisLike = this.handlePostDisLike.bind(this);
     this.handleEditPost = this.handleEditPost.bind(this);
     this.handleEditCancel = this.handleEditCancel.bind(this);
   }
 
   componentWillReceiveProps(nextProps: PostListingProps) {
-    this.setState({
-      my_vote: nextProps.post_view.my_vote,
-      upvotes: nextProps.post_view.counts.upvotes,
-      downvotes: nextProps.post_view.counts.downvotes,
-      score: nextProps.post_view.counts.score,
-    });
-    if (this.props.post_view.post.id !== nextProps.post_view.post.id) {
-      this.setState({ imageExpanded: false });
+    if (this.props !== nextProps) {
+      this.setState({
+        upvoteLoading: false,
+        downvoteLoading: false,
+        purgeLoading: false,
+        reportLoading: false,
+        blockLoading: false,
+        lockLoading: false,
+        deleteLoading: false,
+        removeLoading: false,
+        saveLoading: false,
+        featureCommunityLoading: false,
+        featureLocalLoading: false,
+        banLoading: false,
+        addModLoading: false,
+        addAdminLoading: false,
+        transferLoading: false,
+        imageExpanded: false,
+      });
     }
   }
 
+  get postView(): PostView {
+    return this.props.post_view;
+  }
+
   render() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
 
     return (
-      <div className="post-listing">
+      <div className="post-listing mt-2">
         {!this.state.showEdit ? (
           <>
             {this.listing()}
             {this.state.imageExpanded && !this.props.hideImage && this.img}
-            {post.url && this.showBody && post.embed_title && (
+            {post.url && this.state.showBody && post.embed_title && (
               <MetadataCard post={post} />
             )}
             {this.showBody && this.body()}
           </>
         ) : (
-          <div className="col-12">
-            <PostForm
-              post_view={this.props.post_view}
-              onEdit={this.handleEditPost}
-              onCancel={this.handleEditCancel}
-              enableNsfw={this.props.enableNsfw}
-              enableDownvotes={this.props.enableDownvotes}
-              allLanguages={this.props.allLanguages}
-              siteLanguages={this.props.siteLanguages}
-            />
-          </div>
+          <PostForm
+            post_view={this.postView}
+            crossPosts={this.props.crossPosts}
+            onEdit={this.handleEditPost}
+            onCancel={this.handleEditCancel}
+            enableNsfw={this.props.enableNsfw}
+            enableDownvotes={this.props.enableDownvotes}
+            allLanguages={this.props.allLanguages}
+            siteLanguages={this.props.siteLanguages}
+          />
         )}
       </div>
     );
   }
 
   body() {
-    const body = this.props.post_view.post.body;
+    const body = this.postView.post.body;
     return body ? (
-      <div className="col-12 card my-2 p-2">
+      <article id="postContent" className="col-12 card my-2 p-2">
         {this.state.viewSource ? (
           <pre>{body}</pre>
         ) : (
           <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
         )}
-      </div>
+      </article>
     ) : (
       <></>
     );
   }
 
   get img() {
-    const src = this.imageSrc;
-    return src ? (
+    return this.imageSrc ? (
       <>
         <div className="offset-sm-3 my-2 d-none d-sm-block">
-          <a href={src} className="d-inline-block">
-            <PictrsImage src={src} />
+          <a href={this.imageSrc} className="d-inline-block">
+            <PictrsImage src={this.imageSrc} />
           </a>
         </div>
         <div className="my-2 d-block d-sm-none">
@@ -207,7 +255,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             className="d-inline-block"
             onClick={linkEvent(this, this.handleImageExpandClick)}
           >
-            <PictrsImage src={src} />
+            <PictrsImage src={this.imageSrc} />
           </a>
         </div>
       </>
@@ -217,7 +265,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   imgThumb(src: string) {
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       <PictrsImage
         src={src}
@@ -229,7 +277,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get imageSrc(): string | undefined {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
     const thumbnail = post.thumbnail_url;
 
@@ -249,7 +297,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   thumbnail() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
     const thumbnail = post.thumbnail_url;
 
@@ -318,11 +366,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   createdLine() {
-    const post_view = this.props.post_view;
-    const url = post_view.post.url;
-    const body = post_view.post.body;
+    const post_view = this.postView;
     return (
-      <ul className="list-inline mb-1 text-muted small">
+      <ul className="list-inline mb-1 text-muted small mt-2">
         <li className="list-inline-item">
           <PersonListing person={post_view.creator} />
 
@@ -338,10 +384,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             </span>
           )}
           {this.props.showCommunity && (
-            <span>
-              <span className="mx-1"> {i18n.t("to")} </span>
-              <CommunityLink community={post_view.community} />
-            </span>
+            <>
+              {" "}
+              {i18n.t("to")} <CommunityLink community={post_view.community} />
+            </>
           )}
         </li>
         {post_view.post.language_id !== 0 && (
@@ -354,21 +400,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           </span>
         )}
         <li className="list-inline-item">•</li>
-        {url && !(hostname(url) === getExternalHost()) && (
-          <>
-            <li className="list-inline-item">
-              <a
-                className="text-muted font-italic"
-                href={url}
-                title={url}
-                rel={relTags}
-              >
-                {hostname(url)}
-              </a>
-            </li>
-            <li className="list-inline-item">•</li>
-          </>
-        )}
         <li className="list-inline-item">
           <span>
             <MomentTime
@@ -377,21 +408,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             />
           </span>
         </li>
-        {body && (
-          <>
-            <li className="list-inline-item">•</li>
-            <li className="list-inline-item">
-              <button
-                className="text-muted btn btn-sm btn-link p-0"
-                data-tippy-content={mdNoImages.render(body)}
-                data-tippy-allowHtml={true}
-                onClick={linkEvent(this, this.handleShowBody)}
-              >
-                <Icon icon="book-open" classes="icon-inline mr-1" />
-              </button>
-            </li>
-          </>
-        )}
       </ul>
     );
   }
@@ -401,21 +417,25 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
       <div className={`vote-bar col-1 pr-0 small text-center`}>
         <button
           className={`btn-animate btn btn-link p-0 ${
-            this.state.my_vote === 1 ? "text-info" : "text-muted"
+            this.postView.my_vote == 1 ? "text-info" : "text-muted"
           }`}
-          onClick={this.handlePostLike}
+          onClick={linkEvent(this, this.handleUpvote)}
           data-tippy-content={i18n.t("upvote")}
           aria-label={i18n.t("upvote")}
-          aria-pressed={this.state.my_vote === 1}
+          aria-pressed={this.postView.my_vote === 1}
         >
-          <Icon icon="arrow-up1" classes="upvote" />
+          {this.state.upvoteLoading ? (
+            <Spinner />
+          ) : (
+            <Icon icon="arrow-up1" classes="upvote" />
+          )}
         </button>
         {showScores() ? (
           <div
-            className={`unselectable pointer font-weight-bold text-muted px-1`}
+            className={`unselectable pointer font-weight-bold text-muted px-1 post-score`}
             data-tippy-content={this.pointsTippy}
           >
-            {numToSI(this.state.score)}
+            {numToSI(this.postView.counts.score)}
           </div>
         ) : (
           <div className="p-1"></div>
@@ -423,14 +443,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         {this.props.enableDownvotes && (
           <button
             className={`btn-animate btn btn-link p-0 ${
-              this.state.my_vote === -1 ? "text-danger" : "text-muted"
+              this.postView.my_vote == -1 ? "text-danger" : "text-muted"
             }`}
-            onClick={this.handlePostDisLike}
+            onClick={linkEvent(this, this.handleDownvote)}
             data-tippy-content={i18n.t("downvote")}
             aria-label={i18n.t("downvote")}
-            aria-pressed={this.state.my_vote === -1}
+            aria-pressed={this.postView.my_vote === -1}
           >
-            <Icon icon="arrow-down1" classes="downvote" />
+            {this.state.downvoteLoading ? (
+              <Spinner />
+            ) : (
+              <Icon icon="arrow-down1" classes="downvote" />
+            )}
           </button>
         )}
       </div>
@@ -438,10 +462,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get postLink() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     return (
       <Link
-        className={`d-inline-block ${
+        className={`d-inline ${
           !post.featured_community && !post.featured_local
             ? "text-body"
             : "text-primary"
@@ -449,8 +473,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         to={`/post/${post.id}`}
         title={i18n.t("comments")}
       >
-        <div
-          className="d-inline-block"
+        <span
+          className="d-inline"
           dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
         />
       </Link>
@@ -458,35 +482,29 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   postTitleLine() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
 
     return (
-      <div className="post-title overflow-hidden">
-        <h5>
-          {url ? (
-            this.props.showBody ? (
+      <>
+        <div className="post-title overflow-hidden">
+          <h5 className="d-inline">
+            {url && this.props.showBody ? (
               <a
-                className={`d-inline-block ${
+                className={
                   !post.featured_community && !post.featured_local
                     ? "text-body"
                     : "text-primary"
-                }`}
+                }
                 href={url}
                 title={url}
                 rel={relTags}
-              >
-                <div
-                  className="d-inline-block"
-                  dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
-                />
-              </a>
+                dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
+              ></a>
             ) : (
               this.postLink
-            )
-          ) : (
-            this.postLink
-          )}
+            )}
+          </h5>
           {(url && isImage(url)) ||
             (post.thumbnail_url && (
               <button
@@ -503,7 +521,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               </button>
             ))}
           {post.removed && (
-            <small className="ml-2 text-muted font-italic">
+            <small className="ml-2 badge text-bg-secondary">
               {i18n.t("removed")}
             </small>
           )}
@@ -526,7 +544,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           {post.featured_community && (
             <small
               className="unselectable pointer ml-2 text-muted font-italic"
-              data-tippy-content={i18n.t("featured")}
+              data-tippy-content={i18n.t("featured_in_community")}
+              aria-label={i18n.t("featured_in_community")}
             >
               <Icon icon="pin" classes="icon-inline text-primary" />
             </small>
@@ -534,23 +553,45 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           {post.featured_local && (
             <small
               className="unselectable pointer ml-2 text-muted font-italic"
-              data-tippy-content={i18n.t("featured")}
+              data-tippy-content={i18n.t("featured_in_local")}
+              aria-label={i18n.t("featured_in_local")}
             >
               <Icon icon="pin" classes="icon-inline text-secondary" />
             </small>
           )}
           {post.nsfw && (
-            <small className="ml-2 text-muted font-italic">
+            <small className="ml-2 badge text-bg-danger">
               {i18n.t("nsfw")}
             </small>
           )}
-        </h5>
-      </div>
+        </div>
+        {url && this.urlLine()}
+      </>
+    );
+  }
+
+  urlLine() {
+    const post = this.postView.post;
+    const url = post.url;
+
+    return (
+      <p className="d-flex text-muted align-items-center gap-1 small m-0">
+        {url && !(hostname(url) === getExternalHost()) && (
+          <a
+            className="text-muted font-italic"
+            href={url}
+            title={url}
+            rel={relTags}
+          >
+            {hostname(url)}
+          </a>
+        )}
+      </p>
     );
   }
 
   duplicatesLine() {
-    const dupes = this.props.duplicates;
+    const dupes = this.props.crossPosts;
     return dupes && dupes.length > 0 ? (
       <ul className="list-inline mb-1 small text-muted">
         <>
@@ -572,14 +613,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   commentsLine(mobile = false) {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
 
     return (
-      <div className="d-flex justify-content-start flex-wrap text-muted font-weight-bold mb-1">
+      <div className="d-flex align-items-center justify-content-start flex-wrap text-muted font-weight-bold">
         {this.commentsButton}
         {canShare() && (
           <button
-            className="btn btn-link"
+            className="btn btn-sm btn-link"
             onClick={linkEvent(this, this.handleShare)}
             type="button"
           >
@@ -598,59 +639,103 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         {mobile && !this.props.viewOnly && this.mobileVotes}
         {UserService.Instance.myUserInfo &&
           !this.props.viewOnly &&
-          this.postActions(mobile)}
+          this.postActions()}
       </div>
     );
   }
 
-  postActions(mobile = false) {
+  showPreviewButton() {
+    const post_view = this.postView;
+    const body = post_view.post.body;
+
+    return (
+      <button
+        className="btn btn-link btn-animate text-muted py-0"
+        data-tippy-content={body && mdNoImages.render(body)}
+        data-tippy-allowHtml={true}
+        onClick={linkEvent(this, this.handleShowBody)}
+      >
+        <Icon
+          icon="book-open"
+          classes={classNames("icon-inline mr-1", {
+            "text-success": this.state.showBody,
+          })}
+        />
+      </button>
+    );
+  }
+
+  postActions() {
     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
     // Possible enhancement: Make each button a component.
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
+    const post = post_view.post;
+
     return (
       <>
         {this.saveButton}
         {this.crossPostButton}
-        {mobile && this.showMoreButton}
-        {(!mobile || this.state.showAdvanced) && (
-          <>
-            {!this.myPost && (
+
+        {/**
+         * If there is a URL, or if the post has a body and we were told not to
+         * show the body, show the MetadataCard/body toggle.
+         */}
+        {(post.url || (post.body && !this.props.showBody)) &&
+          this.showPreviewButton()}
+
+        {this.showBody && post_view.post.body && this.viewSourceButton}
+
+        <div className="dropdown">
+          <button
+            className="btn btn-link btn-animate text-muted py-0 dropdown-toggle"
+            onClick={linkEvent(this, this.handleShowAdvanced)}
+            data-tippy-content={i18n.t("more")}
+            data-bs-toggle="dropdown"
+            aria-expanded="false"
+            aria-controls="advancedButtonsDropdown"
+            aria-label={i18n.t("more")}
+          >
+            <Icon icon="more-vertical" inline />
+          </button>
+
+          <ul className="dropdown-menu" id="advancedButtonsDropdown">
+            {!this.myPost ? (
               <>
-                {this.reportButton}
-                {this.blockButton}
+                <li>{this.reportButton}</li>
+                <li>{this.blockButton}</li>
               </>
-            )}
-            {this.myPost && (this.showBody || this.state.showAdvanced) && (
+            ) : (
               <>
-                {this.editButton}
-                {this.deleteButton}
+                <li>{this.editButton}</li>
+                <li>{this.deleteButton}</li>
               </>
             )}
-          </>
-        )}
-        {this.state.showAdvanced && (
-          <>
-            {this.showBody && post_view.post.body && this.viewSourceButton}
+
             {/* Any mod can do these, not limited to hierarchy*/}
             {(amMod(this.props.moderators) || amAdmin()) && (
               <>
-                {this.lockButton}
-                {this.featureButton}
+                <li>
+                  <hr className="dropdown-divider" />
+                </li>
+                <li>{this.lockButton}</li>
+                {this.featureButtons}
               </>
             )}
-            {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}</>}
-          </>
-        )}
-        {!mobile && this.showMoreButton}
+
+            {(this.canMod_ || this.canAdmin_) && (
+              <li>{this.modRemoveButton}</li>
+            )}
+          </ul>
+        </div>
       </>
     );
   }
 
   get commentsButton() {
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       <Link
-        className="btn btn-link text-muted py-0 pl-0 text-muted"
+        className="btn btn-link text-muted pl-0 text-muted"
         title={i18n.t("number_of_comments", {
           count: Number(post_view.counts.comments),
           formattedCount: Number(post_view.counts.comments),
@@ -674,7 +759,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get unreadCount(): number | undefined {
-    const pv = this.props.post_view;
+    const pv = this.postView;
     return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
       ? undefined
       : pv.unread_comments;
@@ -690,37 +775,51 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         <div>
           <button
             className={`btn-animate btn py-0 px-1 ${
-              this.state.my_vote === 1 ? "text-info" : "text-muted"
+              this.postView.my_vote === 1 ? "text-info" : "text-muted"
             }`}
             {...tippy}
-            onClick={this.handlePostLike}
+            onClick={linkEvent(this, this.handleUpvote)}
             aria-label={i18n.t("upvote")}
-            aria-pressed={this.state.my_vote === 1}
+            aria-pressed={this.postView.my_vote === 1}
           >
-            <Icon icon="arrow-up1" classes="icon-inline small" />
-            {showScores() && (
-              <span className="ml-2">{numToSI(this.state.upvotes)}</span>
+            {this.state.upvoteLoading ? (
+              <Spinner />
+            ) : (
+              <>
+                <Icon icon="arrow-up1" classes="icon-inline small" />
+                {showScores() && (
+                  <span className="ml-2">
+                    {numToSI(this.postView.counts.upvotes)}
+                  </span>
+                )}
+              </>
             )}
           </button>
           {this.props.enableDownvotes && (
             <button
               className={`ml-2 btn-animate btn py-0 px-1 ${
-                this.state.my_vote === -1 ? "text-danger" : "text-muted"
+                this.postView.my_vote === -1 ? "text-danger" : "text-muted"
               }`}
-              onClick={this.handlePostDisLike}
+              onClick={linkEvent(this, this.handleDownvote)}
               {...tippy}
               aria-label={i18n.t("downvote")}
-              aria-pressed={this.state.my_vote === -1}
+              aria-pressed={this.postView.my_vote === -1}
             >
-              <Icon icon="arrow-down1" classes="icon-inline small" />
-              {showScores() && (
-                <span
-                  className={classNames("ml-2", {
-                    invisible: this.state.downvotes === 0,
-                  })}
-                >
-                  {numToSI(this.state.downvotes)}
-                </span>
+              {this.state.downvoteLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  <Icon icon="arrow-down1" classes="icon-inline small" />
+                  {showScores() && (
+                    <span
+                      className={classNames("ml-2", {
+                        invisible: this.postView.counts.downvotes === 0,
+                      })}
+                    >
+                      {numToSI(this.postView.counts.downvotes)}
+                    </span>
+                  )}
+                </>
               )}
             </button>
           )}
@@ -730,7 +829,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get saveButton() {
-    const saved = this.props.post_view.saved;
+    const saved = this.postView.saved;
     const label = saved ? i18n.t("unsave") : i18n.t("save");
     return (
       <button
@@ -739,11 +838,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="star"
-          classes={classNames({ "text-warning": saved })}
-          inline
-        />
+        {this.state.saveLoading ? (
+          <Spinner />
+        ) : (
+          <Icon
+            icon="star"
+            classes={classNames({ "text-warning": saved })}
+            inline
+          />
+        )}
       </button>
     );
   }
@@ -761,6 +864,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           search: "",
         }}
         title={i18n.t("cross_post")}
+        data-tippy-content={i18n.t("cross_post")}
+        aria-label={i18n.t("cross_post")}
       >
         <Icon icon="copy" inline />
       </Link>
@@ -770,12 +875,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   get reportButton() {
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(this, this.handleShowReportDialog)}
-        data-tippy-content={i18n.t("show_report_dialog")}
         aria-label={i18n.t("show_report_dialog")}
       >
-        <Icon icon="flag" inline />
+        <Icon classes="mr-1" icon="flag" inline />
+        {i18n.t("create_report")}
       </button>
     );
   }
@@ -783,12 +888,16 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   get blockButton() {
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
-        onClick={linkEvent(this, this.handleBlockUserClick)}
-        data-tippy-content={i18n.t("block_user")}
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
+        onClick={linkEvent(this, this.handleBlockPersonClick)}
         aria-label={i18n.t("block_user")}
       >
-        <Icon icon="slash" inline />
+        {this.state.blockLoading ? (
+          <Spinner />
+        ) : (
+          <Icon classes="mr-1" icon="slash" inline />
+        )}
+        {i18n.t("block_user")}
       </button>
     );
   }
@@ -796,44 +905,37 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   get editButton() {
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(this, this.handleEditClick)}
-        data-tippy-content={i18n.t("edit")}
         aria-label={i18n.t("edit")}
       >
-        <Icon icon="edit" inline />
+        <Icon classes="mr-1" icon="edit" inline />
+        {i18n.t("edit")}
       </button>
     );
   }
 
   get deleteButton() {
-    const deleted = this.props.post_view.post.deleted;
+    const deleted = this.postView.post.deleted;
     const label = !deleted ? i18n.t("delete") : i18n.t("restore");
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(this, this.handleDeleteClick)}
-        data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="trash"
-          classes={classNames({ "text-danger": deleted })}
-          inline
-        />
-      </button>
-    );
-  }
-
-  get showMoreButton() {
-    return (
-      <button
-        className="btn btn-link btn-animate text-muted py-0"
-        onClick={linkEvent(this, this.handleShowAdvanced)}
-        data-tippy-content={i18n.t("more")}
-        aria-label={i18n.t("more")}
-      >
-        <Icon icon="more-vertical" inline />
+        {this.state.deleteLoading ? (
+          <Spinner />
+        ) : (
+          <>
+            <Icon
+              icon="trash"
+              classes={classNames("mr-1", { "text-danger": deleted })}
+              inline
+            />
+            {label}
+          </>
+        )}
       </button>
     );
   }
@@ -856,80 +958,112 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get lockButton() {
-    const locked = this.props.post_view.post.locked;
+    const locked = this.postView.post.locked;
     const label = locked ? i18n.t("unlock") : i18n.t("lock");
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(this, this.handleModLock)}
-        data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="lock"
-          classes={classNames({ "text-danger": locked })}
-          inline
-        />
+        {this.state.lockLoading ? (
+          <Spinner />
+        ) : (
+          <>
+            <Icon
+              icon="lock"
+              classes={classNames("mr-1", { "text-danger": locked })}
+              inline
+            />
+            {label}
+          </>
+        )}
       </button>
     );
   }
 
-  get featureButton() {
-    const featuredCommunity = this.props.post_view.post.featured_community;
+  get featureButtons() {
+    const featuredCommunity = this.postView.post.featured_community;
     const labelCommunity = featuredCommunity
       ? i18n.t("unfeature_from_community")
       : i18n.t("feature_in_community");
 
-    const featuredLocal = this.props.post_view.post.featured_local;
+    const featuredLocal = this.postView.post.featured_local;
     const labelLocal = featuredLocal
       ? i18n.t("unfeature_from_local")
       : i18n.t("feature_in_local");
     return (
-      <span>
-        <button
-          className="btn btn-link btn-animate text-muted py-0 pl-0"
-          onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
-          data-tippy-content={labelCommunity}
-          aria-label={labelCommunity}
-        >
-          <Icon
-            icon="pin"
-            classes={classNames({ "text-success": featuredCommunity })}
-            inline
-          />{" "}
-          Community
-        </button>
-        {amAdmin() && (
+      <>
+        <li>
           <button
-            className="btn btn-link btn-animate text-muted py-0"
-            onClick={linkEvent(this, this.handleModFeaturePostLocal)}
-            data-tippy-content={labelLocal}
-            aria-label={labelLocal}
+            className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
+            onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
+            data-tippy-content={labelCommunity}
+            aria-label={labelCommunity}
           >
-            <Icon
-              icon="pin"
-              classes={classNames({ "text-success": featuredLocal })}
-              inline
-            />{" "}
-            Local
+            {this.state.featureCommunityLoading ? (
+              <Spinner />
+            ) : (
+              <>
+                <Icon
+                  icon="pin"
+                  classes={classNames("mr-1", {
+                    "text-success": featuredCommunity,
+                  })}
+                  inline
+                />
+                {i18n.t("community")}
+              </>
+            )}
           </button>
-        )}
-      </span>
+        </li>
+        <li>
+          {amAdmin() && (
+            <button
+              className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
+              onClick={linkEvent(this, this.handleModFeaturePostLocal)}
+              data-tippy-content={labelLocal}
+              aria-label={labelLocal}
+            >
+              {this.state.featureLocalLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  <Icon
+                    icon="pin"
+                    classes={classNames("mr-1", {
+                      "text-success": featuredLocal,
+                    })}
+                    inline
+                  />
+                  {i18n.t("local")}
+                </>
+              )}
+            </button>
+          )}
+        </li>
+      </>
     );
   }
 
   get modRemoveButton() {
-    const removed = this.props.post_view.post.removed;
+    const removed = this.postView.post.removed;
     return (
       <button
-        className="btn btn-link btn-animate text-muted py-0"
+        className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
         onClick={linkEvent(
           this,
           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
         )}
       >
         {/* TODO: Find an icon for this. */}
-        {!removed ? i18n.t("remove") : i18n.t("restore")}
+        {this.state.removeLoading ? (
+          <Spinner />
+        ) : !removed ? (
+          i18n.t("remove")
+        ) : (
+          i18n.t("restore")
+        )}
       </button>
     );
   }
@@ -939,7 +1073,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
    */
   userActionsLine() {
     // TODO: make nicer
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       this.state.showAdvanced && (
         <>
@@ -966,7 +1100,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                     )}
                     aria-label={i18n.t("unban")}
                   >
-                    {i18n.t("unban")}
+                    {this.state.banLoading ? <Spinner /> : i18n.t("unban")}
                   </button>
                 ))}
               {!post_view.creator_banned_from_community && (
@@ -979,9 +1113,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       : i18n.t("appoint_as_mod")
                   }
                 >
-                  {this.creatorIsMod_
-                    ? i18n.t("remove_as_mod")
-                    : i18n.t("appoint_as_mod")}
+                  {this.state.addModLoading ? (
+                    <Spinner />
+                  ) : this.creatorIsMod_ ? (
+                    i18n.t("remove_as_mod")
+                  ) : (
+                    i18n.t("appoint_as_mod")
+                  )}
                 </button>
               )}
             </>
@@ -1014,7 +1152,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   aria-label={i18n.t("yes")}
                   onClick={linkEvent(this, this.handleTransferCommunity)}
                 >
-                  {i18n.t("yes")}
+                  {this.state.transferLoading ? <Spinner /> : i18n.t("yes")}
                 </button>
                 <button
                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
@@ -1047,7 +1185,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       onClick={linkEvent(this, this.handleModBanSubmit)}
                       aria-label={i18n.t("unban_from_site")}
                     >
-                      {i18n.t("unban_from_site")}
+                      {this.state.banLoading ? (
+                        <Spinner />
+                      ) : (
+                        i18n.t("unban_from_site")
+                      )}
                     </button>
                   )}
                   <button
@@ -1076,9 +1218,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       : i18n.t("appoint_as_admin")
                   }
                 >
-                  {this.creatorIsAdmin_
-                    ? i18n.t("remove_as_admin")
-                    : i18n.t("appoint_as_admin")}
+                  {this.state.addAdminLoading ? (
+                    <Spinner />
+                  ) : this.creatorIsAdmin_ ? (
+                    i18n.t("remove_as_admin")
+                  ) : (
+                    i18n.t("appoint_as_admin")
+                  )}
                 </button>
               )}
             </>
@@ -1089,7 +1235,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   removeAndBanDialogs() {
-    const post = this.props.post_view;
+    const post = this.postView;
     const purgeTypeText =
       this.state.purgeType == PurgeType.Post
         ? i18n.t("purge_post")
@@ -1117,7 +1263,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="btn btn-secondary"
               aria-label={i18n.t("remove_post")}
             >
-              {i18n.t("remove_post")}
+              {this.state.removeLoading ? <Spinner /> : i18n.t("remove_post")}
             </button>
           </form>
         )}
@@ -1179,7 +1325,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 className="btn btn-secondary"
                 aria-label={i18n.t("ban")}
               >
-                {i18n.t("ban")} {post.creator.name}
+                {this.state.banLoading ? (
+                  <Spinner />
+                ) : (
+                  <span>
+                    {i18n.t("ban")} {post.creator.name}
+                  </span>
+                )}
               </button>
             </div>
           </form>
@@ -1206,7 +1358,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="btn btn-secondary"
               aria-label={i18n.t("create_report")}
             >
-              {i18n.t("create_report")}
+              {this.state.reportLoading ? <Spinner /> : i18n.t("create_report")}
             </button>
           </form>
         )}
@@ -1235,7 +1387,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 className="btn btn-secondary"
                 aria-label={purgeTypeText}
               >
-                {purgeTypeText}
+                {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
               </button>
             )}
           </form>
@@ -1245,7 +1397,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   mobileThumbnail() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     return post.thumbnail_url || (post.url && isImage(post.url)) ? (
       <div className="row">
         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
@@ -1262,9 +1414,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   showMobilePreview() {
-    const body = this.props.post_view.post.body;
+    const { body, id } = this.postView.post;
+
     return !this.showBody && body ? (
-      <div className="md-div mb-1 preview-lines">{body}</div>
+      <Link className="text-body" to={`/post/${id}`}>
+        <div className="md-div mb-1 preview-lines">{body}</div>
+      </Link>
     ) : (
       <></>
     );
@@ -1275,7 +1430,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
       <>
         {/* The mobile view*/}
         <div className="d-block d-sm-none">
-          <div className="row">
+          <article className="row post-container">
             <div className="col-12">
               {this.createdLine()}
 
@@ -1290,14 +1445,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               {this.duplicatesLine()}
               {this.removeAndBanDialogs()}
             </div>
-          </div>
+          </article>
         </div>
 
         {/* The larger view*/}
         <div className="d-none d-sm-block">
-          <div className="row">
+          <article className="row post-container">
             {!this.props.viewOnly && this.voteBar()}
-            <div className="col-sm-2 pr-0">
+            <div className="col-sm-2 pr-0 post-media">
               <div className="">{this.thumbnail()}</div>
             </div>
             <div className="col-12 col-sm-9">
@@ -1312,7 +1467,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 </div>
               </div>
             </div>
-          </div>
+          </article>
         </div>
       </>
     );
@@ -1320,97 +1475,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   private get myPost(): boolean {
     return (
-      this.props.post_view.creator.id ==
+      this.postView.creator.id ==
       UserService.Instance.myUserInfo?.local_user_view.person.id
     );
   }
-
-  handlePostLike(event: any) {
-    event.preventDefault();
-    if (!UserService.Instance.myUserInfo) {
-      this.context.router.history.push(`/login`);
-    }
-
-    const myVote = this.state.my_vote;
-    const newVote = myVote == 1 ? 0 : 1;
-
-    if (myVote == 1) {
-      this.setState({
-        score: this.state.score - 1,
-        upvotes: this.state.upvotes - 1,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        score: this.state.score + 2,
-        upvotes: this.state.upvotes + 1,
-        downvotes: this.state.downvotes - 1,
-      });
-    } else {
-      this.setState({
-        score: this.state.score + 1,
-        upvotes: this.state.upvotes + 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreatePostLike = {
-        post_id: this.props.post_view.post.id,
-        score: newVote,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.likePost(form));
-      this.setState(this.state);
-    }
-    setupTippy();
-  }
-
-  handlePostDisLike(event: any) {
-    event.preventDefault();
-    if (!UserService.Instance.myUserInfo) {
-      this.context.router.history.push(`/login`);
-    }
-
-    const myVote = this.state.my_vote;
-    const newVote = myVote == -1 ? 0 : -1;
-
-    if (myVote == 1) {
-      this.setState({
-        score: this.state.score - 2,
-        upvotes: this.state.upvotes - 1,
-        downvotes: this.state.downvotes + 1,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        score: this.state.score + 1,
-        downvotes: this.state.downvotes - 1,
-      });
-    } else {
-      this.setState({
-        score: this.state.score - 1,
-        downvotes: this.state.downvotes + 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreatePostLike = {
-        post_id: this.props.post_view.post.id,
-        score: newVote,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.likePost(form));
-      this.setState(this.state);
-    }
-    setupTippy();
-  }
-
   handleEditClick(i: PostListing) {
     i.setState({ showEdit: true });
   }
@@ -1420,8 +1488,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   // The actual editing is done in the receive for post
-  handleEditPost() {
+  handleEditPost(form: EditPost) {
     this.setState({ showEdit: false });
+    this.props.onPostEdit(form);
   }
 
   handleShare(i: PostListing) {
@@ -1443,61 +1512,44 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handleReportSubmit(i: PostListing, event: any) {
     event.preventDefault();
-    const auth = myAuth();
-    const reason = i.state.reportReason;
-    if (auth && reason) {
-      const form: CreatePostReport = {
-        post_id: i.props.post_view.post.id,
-        reason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.createPostReport(form));
-
-      i.setState({ showReportDialog: false });
-    }
+    i.setState({ reportLoading: true });
+    i.props.onPostReport({
+      post_id: i.postView.post.id,
+      reason: i.state.reportReason ?? "",
+      auth: myAuthRequired(),
+    });
   }
 
-  handleBlockUserClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const blockUserForm: BlockPerson = {
-        person_id: i.props.post_view.creator.id,
-        block: true,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-    }
+  handleBlockPersonClick(i: PostListing) {
+    i.setState({ blockLoading: true });
+    i.props.onBlockPerson({
+      person_id: i.postView.creator.id,
+      block: true,
+      auth: myAuthRequired(),
+    });
   }
 
   handleDeleteClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const deleteForm: DeletePost = {
-        post_id: i.props.post_view.post.id,
-        deleted: !i.props.post_view.post.deleted,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
-    }
+    i.setState({ deleteLoading: true });
+    i.props.onDeletePost({
+      post_id: i.postView.post.id,
+      deleted: !i.postView.post.deleted,
+      auth: myAuthRequired(),
+    });
   }
 
   handleSavePostClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const saved =
-        i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
-      const form: SavePost = {
-        post_id: i.props.post_view.post.id,
-        save: saved,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.savePost(form));
-    }
+    i.setState({ saveLoading: true });
+    i.props.onSavePost({
+      post_id: i.postView.post.id,
+      save: !i.postView.saved,
+      auth: myAuthRequired(),
+    });
   }
 
   get crossPostParams(): PostFormParams {
     const queryParams: PostFormParams = {};
-    const { name, url } = this.props.post_view.post;
+    const { name, url } = this.postView.post;
 
     queryParams.name = name;
 
@@ -1514,7 +1566,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   crossPostBody(): string | undefined {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const body = post.body;
 
     return body
@@ -1546,56 +1598,41 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handleModRemoveSubmit(i: PostListing, event: any) {
     event.preventDefault();
-
-    const auth = myAuth();
-    if (auth) {
-      const form: RemovePost = {
-        post_id: i.props.post_view.post.id,
-        removed: !i.props.post_view.post.removed,
-        reason: i.state.removeReason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.removePost(form));
-      i.setState({ showRemoveDialog: false });
-    }
+    i.setState({ removeLoading: true });
+    i.props.onRemovePost({
+      post_id: i.postView.post.id,
+      removed: !i.postView.post.removed,
+      auth: myAuthRequired(),
+    });
   }
 
   handleModLock(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: LockPost = {
-        post_id: i.props.post_view.post.id,
-        locked: !i.props.post_view.post.locked,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.lockPost(form));
-    }
+    i.setState({ lockLoading: true });
+    i.props.onLockPost({
+      post_id: i.postView.post.id,
+      locked: !i.postView.post.locked,
+      auth: myAuthRequired(),
+    });
   }
 
   handleModFeaturePostLocal(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: FeaturePost = {
-        post_id: i.props.post_view.post.id,
-        feature_type: "Local",
-        featured: !i.props.post_view.post.featured_local,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.featurePost(form));
-    }
+    i.setState({ featureLocalLoading: true });
+    i.props.onFeaturePost({
+      post_id: i.postView.post.id,
+      featured: !i.postView.post.featured_local,
+      feature_type: "Local",
+      auth: myAuthRequired(),
+    });
   }
 
   handleModFeaturePostCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: FeaturePost = {
-        post_id: i.props.post_view.post.id,
-        feature_type: "Community",
-        featured: !i.props.post_view.post.featured_community,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.featurePost(form));
-    }
+    i.setState({ featureCommunityLoading: true });
+    i.props.onFeaturePost({
+      post_id: i.postView.post.id,
+      featured: !i.postView.post.featured_community,
+      feature_type: "Community",
+      auth: myAuthRequired(),
+    });
   }
 
   handleModBanFromCommunityShow(i: PostListing) {
@@ -1636,26 +1673,19 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handlePurgeSubmit(i: PostListing, event: any) {
     event.preventDefault();
-
-    const auth = myAuth();
-    if (auth) {
-      if (i.state.purgeType == PurgeType.Person) {
-        const form: PurgePerson = {
-          person_id: i.props.post_view.creator.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgePerson(form));
-      } else if (i.state.purgeType == PurgeType.Post) {
-        const form: PurgePost = {
-          post_id: i.props.post_view.post.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgePost(form));
-      }
-
-      i.setState({ purgeLoading: true });
+    i.setState({ purgeLoading: true });
+    if (i.state.purgeType == PurgeType.Person) {
+      i.props.onPurgePerson({
+        person_id: i.postView.creator.id,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
+    } else if (i.state.purgeType == PurgeType.Post) {
+      i.props.onPurgePost({
+        post_id: i.postView.post.id,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
     }
   }
 
@@ -1667,88 +1697,70 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     i.setState({ banExpireDays: event.target.value });
   }
 
-  handleModBanFromCommunitySubmit(i: PostListing) {
+  handleModBanFromCommunitySubmit(i: PostListing, event: any) {
     i.setState({ banType: BanType.Community });
-    i.handleModBanBothSubmit(i);
+    i.handleModBanBothSubmit(i, event);
   }
 
-  handleModBanSubmit(i: PostListing) {
+  handleModBanSubmit(i: PostListing, event: any) {
     i.setState({ banType: BanType.Site });
-    i.handleModBanBothSubmit(i);
-  }
-
-  handleModBanBothSubmit(i: PostListing, event?: any) {
-    if (event) event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const ban = !i.props.post_view.creator_banned_from_community;
-      const person_id = i.props.post_view.creator.id;
-      const remove_data = i.state.removeData;
-      const reason = i.state.banReason;
-      const expires = futureDaysToUnixTime(i.state.banExpireDays);
-
-      if (i.state.banType == BanType.Community) {
-        // If its an unban, restore all their data
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-
-        const form: BanFromCommunity = {
-          person_id,
-          community_id: i.props.post_view.community.id,
-          ban,
-          remove_data,
-          reason,
-          expires,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banFromCommunity(form));
-      } else {
-        // If its an unban, restore all their data
-        const ban = !i.props.post_view.creator.banned;
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-        const form: BanPerson = {
-          person_id,
-          ban,
-          remove_data,
-          reason,
-          expires,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banPerson(form));
-      }
+    i.handleModBanBothSubmit(i, event);
+  }
+
+  handleModBanBothSubmit(i: PostListing, event: any) {
+    event.preventDefault();
+    i.setState({ banLoading: true });
 
-      i.setState({ showBanDialog: false });
+    const ban = !i.props.post_view.creator_banned_from_community;
+    // If its an unban, restore all their data
+    if (ban == false) {
+      i.setState({ removeData: false });
+    }
+    const person_id = i.props.post_view.creator.id;
+    const remove_data = i.state.removeData;
+    const reason = i.state.banReason;
+    const expires = futureDaysToUnixTime(i.state.banExpireDays);
+
+    if (i.state.banType == BanType.Community) {
+      const community_id = i.postView.community.id;
+      i.props.onBanPersonFromCommunity({
+        community_id,
+        person_id,
+        ban,
+        remove_data,
+        reason,
+        expires,
+        auth: myAuthRequired(),
+      });
+    } else {
+      i.props.onBanPerson({
+        person_id,
+        ban,
+        remove_data,
+        reason,
+        expires,
+        auth: myAuthRequired(),
+      });
     }
   }
 
   handleAddModToCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: AddModToCommunity = {
-        person_id: i.props.post_view.creator.id,
-        community_id: i.props.post_view.community.id,
-        added: !i.creatorIsMod_,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addModToCommunity(form));
-      i.setState(i.state);
-    }
+    i.setState({ addModLoading: true });
+    i.props.onAddModToCommunity({
+      community_id: i.postView.community.id,
+      person_id: i.postView.creator.id,
+      added: !i.creatorIsMod_,
+      auth: myAuthRequired(),
+    });
   }
 
   handleAddAdmin(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: AddAdmin = {
-        person_id: i.props.post_view.creator.id,
-        added: !i.creatorIsAdmin_,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addAdmin(form));
-      i.setState(i.state);
-    }
+    i.setState({ addAdminLoading: true });
+    i.props.onAddAdmin({
+      person_id: i.postView.creator.id,
+      added: !i.creatorIsAdmin_,
+      auth: myAuthRequired(),
+    });
   }
 
   handleShowConfirmTransferCommunity(i: PostListing) {
@@ -1760,16 +1772,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   handleTransferCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: TransferCommunity = {
-        community_id: i.props.post_view.community.id,
-        person_id: i.props.post_view.creator.id,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.transferCommunity(form));
-      i.setState({ showConfirmTransferCommunity: false });
-    }
+    i.setState({ transferLoading: true });
+    i.props.onTransferCommunity({
+      community_id: i.postView.community.id,
+      person_id: i.postView.creator.id,
+      auth: myAuthRequired(),
+    });
   }
 
   handleShowConfirmTransferSite(i: PostListing) {
@@ -1808,20 +1816,38 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     setupTippy();
   }
 
+  handleUpvote(i: PostListing) {
+    i.setState({ upvoteLoading: true });
+    i.props.onPostVote({
+      post_id: i.postView.post.id,
+      score: newVote(VoteType.Upvote, i.props.post_view.my_vote),
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleDownvote(i: PostListing) {
+    i.setState({ downvoteLoading: true });
+    i.props.onPostVote({
+      post_id: i.postView.post.id,
+      score: newVote(VoteType.Downvote, i.props.post_view.my_vote),
+      auth: myAuthRequired(),
+    });
+  }
+
   get pointsTippy(): string {
     const points = i18n.t("number_of_points", {
-      count: Number(this.state.score),
-      formattedCount: Number(this.state.score),
+      count: Number(this.postView.counts.score),
+      formattedCount: Number(this.postView.counts.score),
     });
 
     const upvotes = i18n.t("number_of_upvotes", {
-      count: Number(this.state.upvotes),
-      formattedCount: Number(this.state.upvotes),
+      count: Number(this.postView.counts.upvotes),
+      formattedCount: Number(this.postView.counts.upvotes),
     });
 
     const downvotes = i18n.t("number_of_downvotes", {
-      count: Number(this.state.downvotes),
-      formattedCount: Number(this.state.downvotes),
+      count: Number(this.postView.counts.downvotes),
+      formattedCount: Number(this.postView.counts.downvotes),
     });
 
     return `${points} • ${upvotes} • ${downvotes}`;
@@ -1829,7 +1855,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   get canModOnSelf_(): boolean {
     return canMod(
-      this.props.post_view.creator.id,
+      this.postView.creator.id,
       this.props.moderators,
       this.props.admins,
       undefined,
@@ -1839,21 +1865,21 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   get canMod_(): boolean {
     return canMod(
-      this.props.post_view.creator.id,
+      this.postView.creator.id,
       this.props.moderators,
       this.props.admins
     );
   }
 
   get canAdmin_(): boolean {
-    return canAdmin(this.props.post_view.creator.id, this.props.admins);
+    return canAdmin(this.postView.creator.id, this.props.admins);
   }
 
   get creatorIsMod_(): boolean {
-    return isMod(this.props.post_view.creator.id, this.props.moderators);
+    return isMod(this.postView.creator.id, this.props.moderators);
   }
 
   get creatorIsAdmin_(): boolean {
-    return isAdmin(this.props.post_view.creator.id, this.props.admins);
+    return isAdmin(this.postView.creator.id, this.props.admins);
   }
 }