Twitterクローンを作ります #27 返信(2)

返信まわりで基本的な動作は前回実装できましたが、細かいところを直していきましょう。

# リンクしない

![スクリーンショット 2022-03-23 21.55.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/13e5f0f3-88ae-de96-219f-6c9e7077c01f.png)

返信画面からプロフィール画面へ遷移すると、現状だと上のような画面になってしまいます。
遷移のタイミングでダイアログを閉じるという方法もありますが、ダイアログから遷移する意味があまりないのでそもそも遷移できなくしてしまいましょう。

返信ボタンやいいねボタンも非表示にしてしまいましょう。

```vue:client/src/components/AvatarPic.vue
<template>
  <router-link
    :to="'/profile/' + user.username"
    :class="{ 'large-link': size === 'large' }"
    v-if="!disableLink"
  >
    <img
      :src="url"
      class="uk-border-circle avatar"
      :class="{ large: size === 'large' }"
    />
  </router-link>
  <img
    v-else
    :src="url"
    class="uk-border-circle avatar"
    :class="{ large: size === 'large' }"
  />
</template>

<script>
import { computed } from "vue";

export default {
  props: {
    user: Object,
    size: String,
    disableLink: {
      type: Boolean,
      default: false,
    },
  },
  setup(props) {
    const imageUrl = (user) => {
      if (!user || !user.display_name) {
        return "https://ui-avatars.com/api/?name=?";
      }

      if (!user.profile_pic) {
        return `https://ui-avatars.com/api/?name=${user.display_name}`;
      }
      return user.profile_pic;
    };
    const url = computed(() => imageUrl(props.user));
    return {
      url,
    };
  },
};
</script>

<style scoped>
.avatar {
  max-width: 50px;
  width: 50px;
  height: 50px;
}

.avatar.large {
  max-width: 132px;
  width: 132px;
  height: 132px;
  border: 4px solid #fff;
}

.large-link {
  z-index: 20000;
}
</style>

```

```vue:client/src/components/DisplayName.vue
<template>
  <div class="uk-text-bold">
    <router-link
      :to="'/profile/' + user.username"
      class="uk-link-text"
      v-if="!disableLink"
    >
      {{ user.display_name }}
    </router-link>
    <span v-else>{{ user.display_name }}</span>
  </div>
</template>

<script>
export default {
  props: {
    user: Object,
    disableLink: {
      type: Boolean,
      default: false,
    },
  },
};
</script>
```


```vue:client/src/components/UsernameLink.vue
<template>
  <span class="uk-text-muted">
    <router-link
      :to="'/profile/' + user.username"
      class="uk-link-text"
      v-if="!disableLink"
    >
      @{{ user.username }}
    </router-link>
    <span v-else>@{{ user.username }}</span>
  </span>
</template>

<script>
export default {
  props: {
    user: Object,
    disableLink: {
      type: Boolean,
      default: false,
    },
  },
};
</script>
```

```vue:client/src/components/PostPanel.vue
<template>
  <div class="uk-margin-right">
    <div class="uk-margin-small-left uk-margin-small-bottom" v-if="isRetweet">
      <router-link
        :to="'/profile/' + post.posted_by.username"
        class="uk-link-text"
      >
        {{ post.posted_by.display_name }}がリツイート
      </router-link>
    </div>
    <div class="uk-flex uk-flex-top">
      <avatar-pic
        :user="displayPost.posted_by"
        :disableLink="disableLink"
      ></avatar-pic>
      <div class="uk-flex uk-flex-left uk-flex-column uk-margin-left uk-flex-1">
        <div class="uk-flex uk-flex-middle uk-flex-row">
          <display-name
            :user="displayPost.posted_by"
            :disableLink="disableLink"
          ></display-name>
          <username-link
            :user="displayPost.posted_by"
            :disableLink="disableLink"
            class="uk-margin-small-left"
          ></username-link>
          <span class="uk-margin-small-left uk-text-muted">
            {{ displayPost.created_at }}
          </span>
          <span class="uk-flex-1"></span>
          <span uk-icon="icon: close; ratio: 0.8"></span>
        </div>
        <div v-if="isReply">
          <router-link
            :to="'/profile/' + displayPost.reply_to.posted_by.username"
          >
            @{{ displayPost.reply_to.posted_by.username }}
          </router-link>
          へ返信
        </div>
        <div class="uk-flex uk-margin-small-top">
          {{ displayPost.content }}
        </div>
        <div
          class="uk-flex uk-flex-between uk-margin-small-top"
          v-if="!disableLink"
        >
          <!-- 返信 -->
          <span class="uk-flex-1 reply">
            <span uk-icon="comment" @click="onReplyClick"></span>
          </span>
          <!-- リツイート -->
          <span
            class="uk-flex-1 retweet"
            :class="{ active: displayPost.retweeting }"
          >
            <span uk-icon="bolt" @click="onRetweetClick"></span>
            <span class="uk-margin-small-left">{{
              displayPost.retweet_count ? displayPost.retweet_count : ""
            }}</span>
          </span>
          <!-- いいね -->
          <span class="uk-flex-1 like" :class="{ active: displayPost.liking }">
            <span uk-icon="heart" @click="onLikeClick"></span>
            <span class="uk-margin-small-left">{{
              displayPost.like_count ? displayPost.like_count : ""
            }}</span>
          </span>
        </div>
      </div>
    </div>
    <hr />
  </div>
</template>

<script>
import { computed } from "vue";
import { useStore } from "vuex";

import AvatarPic from "@/components/AvatarPic.vue";
import DisplayName from "@/components/DisplayName.vue";
import UsernameLink from "@/components/UsernameLink.vue";

export default {
  components: {
    AvatarPic,
    DisplayName,
    UsernameLink,
  },
  props: {
    post: Object,
    disableLink: Boolean,
  },
  setup(props, context) {
    const store = useStore();

    const isRetweet = Boolean(props.post && props.post.retweeted_post);
    const isReply = Boolean(props.post && props.post.reply_to);
    const displayPost = computed(() =>
      isRetweet ? props.post.retweeted_post : props.post
    );

    const onLikeClick = () => {
      if (displayPost.value.liking) {
        store.dispatch("unlikePost", { post: displayPost.value });
      } else {
        store.dispatch("likePost", { post: displayPost.value });
      }
    };

    const onRetweetClick = () => {
      if (displayPost.value.retweeting) {
        store.dispatch("unretweetPost", { post: displayPost.value });
      } else {
        store.dispatch("retweetPost", { post: displayPost.value });
      }
    };

    const onReplyClick = () => {
      context.emit("clickReply", displayPost.value);
    };

    return {
      isRetweet,
      isReply,
      displayPost,
      onLikeClick,
      onRetweetClick,
      onReplyClick,
    };
  },
};
</script>

<style scoped>
.like.active {
  color: red;
}

.retweet.active {
  color: green;
}

.like,
.retweet,
.reply {
  cursor: pointer;
}
</style>
```

```vue:client/src/components/ReplyModal.vue
<template>
  <div class="uk-modal-dialog uk-modal-body">
    <post-panel
      v-if="Boolean(post)"
      :post="post"
      :disableLink="true"
    ></post-panel>
    <div class="uk-flex uk-flex-top">
      <avatar-pic :user="userLoggedIn" :disableLink="true"></avatar-pic>
      <textarea
        v-model="content"
        placeholder="返信をツイート"
        class="uk-textarea uk-margin-left textarea"
      ></textarea>
    </div>
    <p class="uk-text-right">
      <button
        class="uk-button uk-button-primary"
        type="button"
        @click="onReplyClick"
      >
        返信
      </button>
    </p>
  </div>
</template>

<script>
import UIkit from "uikit";
import { computed, ref } from "vue";
import { useStore } from "vuex";

import AvatarPic from "@/components/AvatarPic.vue";
import PostPanel from "@/components/PostPanel.vue";

export default {
  components: {
    AvatarPic,
    PostPanel,
  },
  setup(_, context) {
    const store = useStore();

    const post = computed(() => store.state.replyTo);

    const userLoggedIn = computed(() => store.state.userLoggedIn);

    const onReplyClick = () => {
      store
        .dispatch("createPost", {
          content: content.value,
          reply_to: post.value._id,
        })
        .then(() => {
          content.value = "";
          context.emit("closeModal");
        })
        .catch((error) => {
          UIkit.notification(error.response.data.error.message, {
            status: "danger",
          });
        });
    };
    const content = ref("");
    return {
      post,
      userLoggedIn,
      content,
      onReplyClick,
    };
  },
};
</script>

<style scoped>
.textarea {
  resize: none;
  border: 0;
  height: 100px;
  margin-top: 5px;
}
</style>
```

![スクリーンショット 2022-03-23 22.29.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/653c0ac0-8933-9353-616c-b9ffabe26f1a.png)

リンクさせないようにして、不要なボタンも消しました。

# モーダルの閉じるボタン

モーダルに閉じるボタンを追加しておきましょう。

```vue:client/src/components/ReplyModal.vue
<template>
  <div class="uk-modal-dialog uk-modal-body">
    <button class="uk-modal-close-default" type="button" uk-close></button>
...

```

そういえばPostPanelで投稿の削除用のバツボタンを表示だけしていたので変な表示になってしまっていますね...
投稿の削除機能を実装するときに直しておきましょう。

![スクリーンショット 2022-03-23 22.33.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/0bfb6542-9340-c91b-d615-c85fd0ec35c0.png)

# 返信の件数表示

投稿に返信された件数を表示できるようにしましょう。

```python:server/models/post.py
...
    def __init__(
        self,
        content: str,
        posted_by: str,
        retweeted_post: str = None,
        reply_to: str = None,
        reply_count: int = 0,
        like_count: int = 0,
        retweet_count: int = 0,
        created_at: datetime = None,
        updated_at: datetime = None,
    ):
        self.content = content
        self.posted_by = posted_by
        self.retweeted_post = retweeted_post
        self.reply_to = reply_to
        self.reply_count = reply_count
        self.like_count = like_count
        self.retweet_count = retweet_count
        if not created_at:
            created_at = datetime.now()
        self.created_at = created_at
        if not updated_at:
            updated_at = datetime.now()
        self.updated_at = updated_at
...
```

```python:server/apis/posts.py
...
@app.route("/api/posts", methods=["POST"])
@login_required()
def create_post():
    body = request.json

    error_message = Post.validate(body)
    if error_message:
        return error(error_message)

    if session["user"]["_id"] != body["posted_by"]:
        return error("投稿者が不正です")

    last = Post._collection.find_one(
        {"posted_by": body["posted_by"]}, sort=[("created_at", pymongo.DESCENDING)]
    )

    if last and last["content"] == body["content"]:
        return error("すでに同じ内容のツイートが投稿されています")

    reply_to = None
    if "reply_to" in body and body["reply_to"]:
        reply_to = body["reply_to"]

    post = Post(content=body["content"], posted_by=body["posted_by"], reply_to=reply_to)

    post.create()

    if "reply_to" in body and body["reply_to"]:
        reply_count = Post._collection.count_documents({"reply_to": ObjectId(reply_to)})
        Post._collection.find_one_and_update(
            {"_id": ObjectId(reply_to)},
            {"$set": {"reply_count": reply_count}}
        )

    return jsonify(_populate(vars(post)))
...

```

返信数も表示できるようになりました。

画面で、返信後に返信先の表示も更新しておきましょう。

```javascript:client/src/store/index.js
...
    async createPost({ commit, state }, args) {
      const body = {
        content: args.content,
        posted_by: state.userLoggedIn._id,
      };
      if (args.reply_to) {
        body.reply_to = args.reply_to;
      }
      return axios.post("/api/posts", body).then((resp) => {
        commit("prependPost", { post: resp.data });
        if (resp.data.reply_to) {
          // 返信を投稿した場合は返信先の返信件数の表示を更新する
          commit("updatePost", { post: resp.data.reply_to });
        }
      });
    },
...

```

返信機能はこれでいいでしょう。

つぶやき関連の機能で残っているのは以下になります。

- つぶやきページ
- 投稿の削除
- ホーム画面に表示するつぶやきをフォロワーのもののみにする
- プロフィール画面の各タブの実装

返信機能をつくったので次はつぶやきページを作っていきましょう。