Twitterクローンを作ります #24 フォロワー一覧

フォロワーとフォロー中のユーザー一覧を表示する画面を作っていきます。

作りとしては同じになりますが、リンク等しやすいように `/profile/xxxx/following` と `/profile/xxxx/followers` のように別のURLを割り当てたいと思います。

# 画面の作成

ルーターは下記のように設定します。

```javascript:client/src/router/index.js
...
  {
    path: "/profile/:username/following",
    name: "following",
    meta: { loginRequired: true, followers: false },
    component: () => import("../views/FollowerView.vue"),
  },
  {
    path: "/profile/:username/followers",
    name: "followers",
    meta: { loginRequired: true, followers: true },
    component: () => import("../views/FollowerView.vue"),
  },
...
```

同じコンポーネントを利用し、metaで挙動を切り替えるようにしましょう。

```vue:client/src/views/FollowerView.vue
<template>
  <div class="uk-margin-right">
    <div class="uk-margin-top uk-margin-bottom">
      <div class="uk-text-bold">hoge</div>
      <div class="uk-text-muted uk-text-small">@hoge</div>
    </div>
    <div>
      <ul uk-tab class="uk-child-width-expand">
        <li :class="{ 'uk-active': isFollowers }">
          <router-link to="/profile/hoge/followers">フォロワー</router-link>
        </li>
        <li :class="{ 'uk-active': !isFollowers }">
          <router-link to="/profile/hoge/following">フォロー中</router-link>
        </li>
      </ul>
    </div>
    <div>
      <user-panel></user-panel>
      <user-panel></user-panel>
    </div>
  </div>
</template>

<script>
import { useRoute } from "vue-router";

import UserPanel from "@/components/UserPanel.vue";

export default {
  components: {
    UserPanel,
  },
  setup() {
    const route = useRoute();
    const isFollowers = route.meta.followers;
    return {
      isFollowers,
    };
  },
};
</script>
```

```vue:client/src/components/UserPanel.vue
<template>
  <div>
    <div class="uk-flex uk-flex-top">
      <img
        src="https://ui-avatars.com/api/?name=?"
        class="uk-border-circle avatar"
      />
      <div
        class="uk-flex uk-flex-left uk-flex-column uk-flex-1 uk-margin-small-left"
      >
        <div class="uk-flex uk-flex-row uk-flex-center">
          <div class="uk-flex-1">
            <div class="uk-text-bold">ほげ山太郎</div>
            <div>
              <span class="uk-text-muted uk-margin-small-right">@hogeyama</span>
              <span class="uk-label">フォローされています</span>
            </div>
          </div>
          <div>
            <button class="uk-button uk-button-small uk-button-secondary">
              フォロー
            </button>
          </div>
        </div>
        <div class="uk-margin-small-top">
          Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
          eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
          minimveniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
          ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
          voluptate velit esse cillum dolore eu fugiat nulla pariatur.
        </div>
      </div>
    </div>
    <hr />
  </div>
</template>

<style scoped>
.avatar {
  width: 50px;
  height: 50px;
}
</style>
```

![スクリーンショット 2022-03-21 12.39.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/30fafafa-bcb9-bec7-989d-d9cb9c2057c8.png)

UIKitにグレーのラベルがなかったのでデフォルトの青にしました。見た目はだいたいこんなものでいいでしょう。

# API

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

@app.route("/api/users/<username>/following", methods=["GET"])
@login_required()
def list_following_users(username):
    user = User._collection.find_one({"username": username})
    if not user:
        return error("ユーザーが存在しません。", 404)
    result = []
    for user_id in user.following:
        u = User._collection.find_one({"_id": ObjectId(user_id)})
        if u:
            del u["hashed_password"]
            result.append(u)
    return jsonify(result)


@app.route("/api/users/<username>/followers", methods=["GET"])
@login_required()
def list_followers(username):
    user = User._collection.find_one({"username": username})
    if not user:
        return error("ユーザーが存在しません。", 404)
    result = []
    for user_id in user.followers:
        u = User._collection.find_one({"_id": ObjectId(user_id)})
        if u:
            del u["hashed_password"]
            result.append(u)
    return jsonify(result)

```

ここに限った話ではないですが、いずれページングというかカーソル的な仕組みを入れて全件取得ではない形にしていかないとですね。
今はこのままにしておきます。

# 画面の動き

```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: [],
    },
  },
  getters: {},
  mutations: {
...
    },
    setProfileUsers(state, args) {
      state.profile.users = args.users;
    },
  },
  actions: {
...
    },
    async loadFollowers({ commit }, args) {
      return axios
        .get(`/api/users/${args.username}/followers`)
        .then((resp) => {
          commit("setProfileUsers", { users: resp.data });
        })
        .catch(defaultErrorHandler);
    },
    async loadFollowingUsers({ commit }, args) {
      return axios
        .get(`/api/users/${args.username}/following`)
        .then((resp) => {
          commit("setProfileUsers", { users: resp.data });
        })
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});

```

```vue:client/src/components/UserPanel.vue
<template>
  <div>
    <div class="uk-flex uk-flex-top">
      <img :src="url" class="uk-border-circle avatar" />
      <div
        class="uk-flex uk-flex-left uk-flex-column uk-flex-1 uk-margin-small-left"
      >
        <div class="uk-flex uk-flex-row uk-flex-center">
          <div class="uk-flex-1">
            <div class="uk-text-bold">{{ user.display_name }}</div>
            <div>
              <span class="uk-text-muted uk-margin-small-right"
                >@{{ user.username }}</span
              >
              <span class="uk-label" v-if="isFollowed"
                >フォローされています</span
              >
            </div>
          </div>
          <div>
            <button
              v-if="!isMe"
              class="uk-button uk-button-small"
              :class="isFollowing ? 'uk-button-default' : 'uk-button-secondary'"
              @click="onFollowClick"
            >
              {{ isFollowing ? "フォロー中" : "フォロー" }}
            </button>
          </div>
        </div>
        <div class="uk-margin-small-top">
          {{ user.description }}
        </div>
      </div>
    </div>
    <hr />
  </div>
</template>

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

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

export default {
  props: {
    user: Object,
  },
  setup(props) {
    const store = useStore();

    const isFollowing = computed(() =>
      store.state.userLoggedIn.following.includes(props.user._id)
    );
    const isFollowed = computed(() =>
      store.state.userLoggedIn.followers.includes(props.user._id)
    );

    const username = ref(props.user.username);

    const isMe = props.user._id === store.state.userLoggedIn._id;

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

    const url = imageUrl(props.user);
    return {
      isMe,
      url,
      isFollowing,
      isFollowed,
      onFollowClick,
    };
  },
};
</script>


<style scoped>
.avatar {
  width: 50px;
  height: 50px;
}
</style>
```

```vue:client/src/views/FollowerView.vue
<template>
  <div class="uk-margin-right">
    <div class="uk-margin-top uk-margin-bottom">
      <div class="uk-text-bold">{{ user.display_name }}</div>
      <div class="uk-text-muted uk-text-small">@{{ user.username }}</div>
    </div>
    <div>
      <ul uk-tab class="uk-child-width-expand">
        <li :class="{ 'uk-active': isFollowers }">
          <router-link :to="'/profile/' + username + '/followers'"
            >フォロワー</router-link
          >
        </li>
        <li :class="{ 'uk-active': !isFollowers }">
          <router-link :to="'/profile/' + username + '/following'"
            >フォロー中</router-link
          >
        </li>
      </ul>
    </div>
    <div>
      <user-panel
        v-for="user in users"
        :key="user._id"
        :user="user"
      ></user-panel>
    </div>
  </div>
</template>

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

import UserPanel from "@/components/UserPanel.vue";

export default {
  components: {
    UserPanel,
  },
  setup() {
    const route = useRoute();
    const store = useStore();
    let isFollowers = route.meta.followers;
    const username = route.params.username;
    const user = computed(() => store.state.profile.user);

    watch(
      () => route.meta.followers,
      async () => {
        isFollowers = route.meta.followers;
        if (isFollowers) {
          store.dispatch("loadFollowers", { username });
        } else {
          store.dispatch("loadFollowingUsers", { username });
        }
        store.dispatch("loadProfileUser", { username });
      },
      {
        immediate: true,
      }
    );

    const users = computed(() => store.state.profile.users);
    return {
      isFollowers,
      user,
      users,
      username,
    };
  },
};
</script>
```

![スクリーンショット 2022-03-21 14.49.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/2a3de2a6-b362-4987-cdaa-b5c3d83c1f40.png)

フォロー中/フォロワー一覧画面もこれでいいでしょう。
基本的な機能がだいぶ出来上がってきましたね。

次回は返信機能あたりをやっていきましょう。