Twitterクローンを作ります #26 返信(1)

つぶやきに返信できるようにしていきましょう。

# 画面

```vue:client/src/components/ReplyModal.vue
<template>
  <div class="uk-modal-dialog uk-modal-body">
    <post-panel :post="dummyPost"></post-panel>
    <div class="uk-flex uk-flex-top">
      <avatar-pic :user="userLoggedIn"></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">返信</button>
    </p>
  </div>
</template>

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

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

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

    const dummyPost = {
      content: "ダミーのつぶやき",
      posted_by: {
        username: "dummy_user",
        display_name: "ダミーのユーザー",
      },
      created_at: "2時間前",
    };

    const userLoggedIn = computed(() => store.state.userLoggedIn);
    return {
      dummyPost,
      userLoggedIn,
    };
  },
};
</script>

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

![スクリーンショット 2022-03-23 17.41.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/d10ca439-68c6-ea0d-5833-b29d56d7540e.png)

返信ボタンを押したら上記のようなモーダルを表示して返信するようにしましょう。
ユーザー名等をクリックしてページを遷移すると表示がおかしくなるのであとでリンクしないようにしておきましょう。
いいねボタン等もここでは不要なので非表示にします。

# API

投稿自体は `POST /api/posts` をほぼそのまま利用できますが、返信であることを表現できるようにしましょう。

```python:server/models/post.py

...

    def __init__(
        self,
        content: str,
        posted_by: str,
        retweeted_post: str = None,
        reply_to: str = None,
        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.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

...

```

reply_to というフィールドに返信先の投稿のIDを保持するようにしました。

```python:server/apis/posts.py
...

def _populate(post):
    if not post:
        return post
    posted_by = User._collection.find_one({"_id": post["posted_by"]})
    if posted_by:
        del posted_by["hashed_password"]
        post["posted_by"] = posted_by
    if "retweeted_post" in post and post["retweeted_post"]:
        post["retweeted_post"] = _populate(
            Post._collection.find_one({"_id": post["retweeted_post"]})
        )
    if "reply_to" in post and post["reply_to"]:
        post["reply_to"] = _populate(
            Post._collection.find_one({"_id": post["reply_to"]})
        )
    return post

```

API側はこれでいいでしょう。

# 画面の動き

まずは各投稿の吹き出しアイコンをクリックしたらモーダルを表示するようにしましょう。
PostPanel.vue からイベントを投げて、HomeView.vue でそのイベントを捕まえてあげれば良さそうです。


```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"></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"></display-name>
          <username-link
            :user="displayPost.posted_by"
            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 class="uk-flex uk-margin-small-top">
          {{ displayPost.content }}
        </div>
        <div class="uk-flex uk-flex-between uk-margin-small-top">
          <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,
  },
  setup(props, context) {
    const store = useStore();

    const isRetweet = Boolean(props.post && props.post.retweeted_post);
    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,
      displayPost,
      onLikeClick,
      onRetweetClick,
      onReplyClick,
    };
  },
};
</script>

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

.retweet.active {
  color: green;
}

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

```vue:client/src/views/HomeView.vue
<template>
  <div>
    <h3 class="uk-margin-top">Home</h3>
    <post-edit></post-edit>
    <hr class="uk-margin-right" />
    <div>
      <post-panel
        v-for="post in posts"
        :key="post._id"
        :post="post"
        @clickReply="onReplyClick"
      ></post-panel>
    </div>
    <div id="reply-modal" uk-modal>
      <reply-modal></reply-modal>
    </div>
    <button uk-toggle="target: #reply-modal">open</button>
  </div>
</template>

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

import PostEdit from "@/components/PostEdit.vue";
import PostPanel from "@/components/PostPanel.vue";
import ReplyModal from "@/components/ReplyModal.vue";

export default {
  components: {
    PostEdit,
    PostPanel,
    ReplyModal,
  },
  setup() {
    const store = useStore();

    onMounted(() => {
      store.dispatch("loadPosts");
    });

    const onReplyClick = (post) => {
      store.dispatch("openReplyModal", { post }).then(() => {
        const element = document.getElementById("reply-modal");
        UIkit.modal(element).show();
      });
    };

    return {
      posts: computed(() => store.state.posts),
      onReplyClick,
    };
  },
};
</script>

```

```javascript:client/src/store/index.js
import axios from "axios";
import UIkit from "uikit";
import { createStore } from "vuex";

import router from "@/router";

function defaultErrorHandler(error) {
  if (error.response.status === 401) {
    router.push("/login");
  } else {
    UIkit.notification(error.response.data.error.message, {
      status: "danger",
    });
  }
}

export default createStore({
  state: {
    userLoggedIn: undefined,
    posts: [],
    profile: {
      user: {
        display_name: "",
        username: "",
      },
      users: [],
    },
    replyTo: undefined,
  },
  getters: {},
  mutations: {
...
    },
    setReplyTo(state, args) {
      state.replyTo = args.post;
    },
  },
  actions: {
...
    },
    async openReplyModal({ commit }, args) {
      return commit("setReplyTo", { post: args.post });
    },
  },
  modules: {},
});
```

モーダルに表示する投稿のデータの受け渡しはstoreを利用しました。

ちょっとPostEdit.vueとコピペになってしまいますが、ReplyModalから返信できるようにしましょう。

```vue:client/src/components/ReplyModal.vue
<template>
  <div class="uk-modal-dialog uk-modal-body">
    <post-panel v-if="Boolean(post)" :post="post"></post-panel>
    <div class="uk-flex uk-flex-top">
      <avatar-pic :user="userLoggedIn"></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>

```

最後に、PostPanelを修正して返信であることを表示しましょう。

```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"></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"></display-name>
          <username-link
            :user="displayPost.posted_by"
            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">
          @{{ displayPost.reply_to.posted_by.username }} へ返信
        </div>
        <div class="uk-flex uk-margin-small-top">
          {{ displayPost.content }}
        </div>
        <div class="uk-flex uk-flex-between uk-margin-small-top">
          <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,
  },
  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>

```

修正すべき点が多々ありますが、返信できるようになりました。
次回は細かいところを直していきましょう。