Twitterクローンを作ります #22 フォロー機能

今回はフォロー機能を作っていきます。
ベースの講座と同様に、users.following, users.followers にフォロー中のユーザー
, フォローされているユーザーの ID の配列をそれぞれ保持する形で実装しようと思います。

# API

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

class User:
    _collection = db["users"]

...

    def __init__(self, display_name: str, username: str, password: str, email: str):
        self.display_name = display_name
        self.username = username
        self.hashed_password = self.encrypt(password)
        self.email = email
        self.following = []
        self.followers = []

...

```

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

@app.route("/api/users/<username>/follow", methods=["POST"])
@login_required()
def follow_user(username):
    user = User._collection.find_one({"username": username})
    if not user:
        return jsonify({"error": {"message": "ユーザーが存在しません。"}}), 404
    own_id = session['user']['_id']
    user_id = user['_id']
    User._collection.find_one_and_update(
        {"username": username},
        {
          "$addToSet": {
            "followers": ObjectId(own_id)
          }
        },
        return_document=pymongo.ReturnDocument.AFTER,
    )
    me = User._collection.find_one_and_update(
        {"_id": ObjectId(own_id)},
        {
          "$addToSet": {
            "following": ObjectId(user_id)
          }
        },
        return_document=pymongo.ReturnDocument.AFTER,
    )
    del me['hashed_password']
    me['following'] = [str(_id) for _id in me['following']]
    me['followers'] = [str(_id) for _id in me['wollowers']]
    return jsonify(me)


@app.route("/api/users/<username>/follow", methods=["DELETE"])
@login_required()
def unfollow_user(username):
    user = User._collection.find_one({"username": username})
    if not user:
        return jsonify({"error": {"message": "ユーザーが存在しません。"}}), 404
    own_id = session['user']['_id']
    user_id = user['_id']
    User._collection.find_one_and_update(
        {"username": username},
        {
          "$pull": {
            "followers": ObjectId(own_id)
          }
        },
        return_document=pymongo.ReturnDocument.AFTER,
    )
    me = User._collection.find_one_and_update(
        {"_id": ObjectId(own_id)},
        {
          "$pull": {
            "following": ObjectId(user_id)
          }
        },
        return_document=pymongo.ReturnDocument.AFTER,
    )
    del me['hashed_password']
    me['following'] = [str(_id) for _id in me['following']]
    me['followers'] = [str(_id) for _id in me['wollowers']]
    return jsonify(me)
```

とりあえずAPIはこんな感じでしょう。


# 画面

自分以外のユーザーのプロフィール画面で、フォローボタンを表示しましょう。

```javascript:client/src/store/index.js
...
    followUser({ commit }, username) {
      return axios.post(`/api/users/${username}/follow`).then((resp) => {
        commit("setUserLoggedIn", resp.data);
      });
    },
    unfollowUser({ commit }, username) {
      return axios.delete(`/api/users/${username}/follow`).then((resp) => {
        commit("setUserLoggedIn", resp.data);
      });
    },
...
```

```vue:client/src/views/ProfileView.vue
<template>
  <div class="uk-width-1-1 uk-position-relative">
    <h3 class="uk-margin-small-top uk-margin-small-bottom">
      {{ username }}
    </h3>
    <div class="cover uk-margin-right"></div>
    <img
      :src="imageUrl(user)"
      class="uk-position-absolute uk-border-circle uk-margin-small-left avatar"
    />
    <div class="uk-width-1-1 uk-position-absolute" v-if="user._id">
      <div
        class="uk-flex uk-flex-row uk-flex-top uk-margin-right uk-margin-small-top buttons"
      >
        <div class="uk-flex-1"></div>
        <button
          v-if="isMe"
          class="uk-button uk-button-default uk-button-small uk-text-bold"
        >
          プロフィールを編集
        </button>
        <button
          v-else
          class="follow uk-button uk-button-small uk-text-bold"
          :class="isFollowing ? 'uk-button-default' : 'uk-button-secondary'"
          @click="onFollowClick"
        >
          {{ isFollowing ? "フォロー中" : "フォロー" }}
        </button>
      </div>
      <div class="uk-flex uk-flex-column uk-flex-left uk-margin-small-left">
        <div class="uk-text-large uk-text-bold">{{ user.display_name }}</div>
        <div>@{{ user.username }}</div>
        <div class="uk-margin-small-top">{{ user.description }}</div>
        <div class="uk-margin-small-top">
          <span class="uk-text-bold">{{ user.following.length }}</span>
          <span class="uk-margin-right">フォロー中</span>
          <span class="uk-text-bold">{{ user.followers.length }}</span>
          <span>フォロワー</span>
        </div>
      </div>
      <div class="uk-margin-top uk-margin-right">
        <ul class="uk-child-width-expand" ref="tabs" uk-tab uk-switcher>
          <li><a>ツイート</a></li>
          <li><a>ツイートと返信</a></li>
          <li><a>いいね</a></li>
        </ul>
        <ul class="uk-switcher">
          <div>ツイートです</div>
          <div>ツイートと返信です</div>
          <div>いいねです</div>
        </ul>
      </div>
    </div>
    <div class="uk-position-absolute error-message" v-else>
      <h3>ユーザーが存在しません</h3>
    </div>
  </div>
</template>

<script>
import { computed, onBeforeMount, ref } from "vue";
import { useRoute } from "vue-router";
import { useStore } from "vuex";

import { imageUrl } from "@/functions/avatar.js";

export default {
  setup() {
    const route = useRoute();
    const store = useStore();

    const username = route.params.username
      ? route.params.username
      : store.state.userLoggedIn.username;
    const isMe = ref(false);
    const isFollowing = computed(
      () =>
        store.state.userLoggedIn.following &&
        store.state.userLoggedIn.following.includes(
          store.state.profile.user._id
        )
    );

    onBeforeMount(async () => {
      await store.dispatch("loadProfileUser", username).catch(() => {
        // noop
      });
      isMe.value = username === store.state.userLoggedIn.username;
    });

    const user = computed(() => store.state.profile.user);

    const onFollowClick = () => {
      if (isFollowing.value) {
        store
          .dispatch("unfollowUser", username)
          .then(() => store.dispatch("loadProfileUser", username));
      } else {
        store
          .dispatch("followUser", username)
          .then(() => store.dispatch("loadProfileUser", username));
      }
    };

    return {
      isMe,
      isFollowing,
      username,
      user,
      imageUrl,
      onFollowClick,
    };
  },
};
</script>

<style scoped>
.cover {
  background-color: lightslategray;
  height: 180px;
}

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

.buttons {
  height: 60px;
}

.error-message {
  margin-top: 30px;
  margin-left: 132px;
}
</style>
```

一応これでフォローとフォロー解除ができるようにはなりました。
そろそろコードがごちゃごちゃしてきたので次回はリファクタリングをしていきたいと思います。