Twitterクローンを作ります #41 チャット機能(3)

今回はチャット一覧画面を作っていきましょう。

# API

自分が含まれるチャットを更新順に返すAPIを作ります。

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

    @classmethod
    def list_chats(cls, user_id):
        pipelines = [
            {
                "$match": {
                    "$expr": {
                        "$in": [
                            ObjectId(user_id),
                            "$users",
                        ]
                    }
                }
            },
            {
                "$lookup": {
                    "from": "users",
                    "localField": "users",
                    "foreignField": "_id",
                    "as": "users",
                }
            },
            {
                "$sort": {
                    "updated_at": pymongo.DESCENDING,
                }
            }
        ]

        return cls._collection.aggregate(pipelines)
```

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

@app.route("/api/chats", methods=["GET"])
@login_required()
def list_chats():
    chats = [chat for chat in Chat.list_chats(session["user"]["_id"])]
    return jsonify(chats)
```


# 画面

```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: [],
      tweets: [],
      tweetsAndReplies: [],
      likes: [],
    },
    search: {
      posts: [],
      users: [],
    },
    chat: {
      users: [],
      chats: [],
    },
    notifications: [],
    unreadNotificationCount: 0,
    replyTo: undefined,
    post: undefined,
  },
  getters: {},
  mutations: {
...
    setChats(state, args) {
      state.chat.chats = args.chats;
    },
  },
  actions: {
...
    async loadChats({ commit }) {
      return axios
        .get("/api/chats")
        .then((resp) => {
          commit("setChats", { chats: resp.data });
        })
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});
```

```vue:client/src/views/ChatsView.vue
<template>
  <div class="uk-margin-right">
    <div class="uk-flex uk-flex-row uk-flex-middle">
      <h3 class="uk-margin-top">メッセージ</h3>
      <span class="uk-flex-1"></span>
      <span uk-icon="plus" class="clickable" @click="onNewClick"></span>
    </div>
    <div>
      <div
        v-for="chat in chats"
        :key="chat._id"
        @click="onChatClick(chat)"
        class="clickable"
      >
        <div class="uk-flex uk-flex-row uk-flex-middle">
          <div
            class="image-container uk-position-relative uk-margin-small-right uk-flex uk-flex-center multi"
            v-if="isMulti(chat)"
          >
            <img
              class="avatar uk-position-absolute"
              :src="getUserImage(chat, 0)"
            />
            <img
              class="avatar uk-position-absolute"
              :src="getUserImage(chat, 1)"
            />
          </div>
          <div
            class="image-container uk-position-relative uk-margin-small-right uk-flex uk-flex-center"
            v-else
          >
            <img class="avatar" :src="getUserImage(chat, 0)" />
          </div>
          <div class="uk-flex-1 uk-flex uk-flex-column uk-width-expand">
            <div class="uk-text-bold uk-text-truncate uk-margin-right">
              {{ chatName(chat) }}
            </div>
            <div
              class="uk-text-small uk-text-muted uk-text-truncate uk-margin-right"
            >
              {{ latestMessage(chat) }}
            </div>
          </div>
        </div>
        <hr />
      </div>
    </div>
  </div>
</template>

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

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

    store.dispatch("loadChats");

    const chats = computed(() => store.state.chat.chats);

    const chatName = (chat) => {
      if (chat.name) {
        return chat.name;
      }
      return chat.users
        .filter((u) => u._id !== store.state.userLoggedIn._id)
        .map((u) => u.display_name)
        .join(", ");
    };

    const latestMessage = (chat) => {
      if (chat.latestMessage) {
        return chat.latestMessage;
      }
      return "(まだメッセージがありません)";
    };

    const isMulti = (chat) => {
      return chat.users.length > 2;
    };

    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 getUserImage = (chat, idx) => {
      return chat.users
        .filter((u) => u._id !== store.state.userLoggedIn._id)
        .map((u) => imageUrl(u))[idx];
    };

    const router = useRouter();
    const onNewClick = () => {
      router.push({ name: "newChat" });
    };

    const onChatClick = (chat) => {
      router.push({ name: "chat", params: { chatId: chat._id } });
    };

    return {
      chats,
      chatName,
      latestMessage,
      isMulti,
      getUserImage,
      onNewClick,
      onChatClick,
    };
  },
};
</script>

<style scoped>
.image-container {
  width: 40px;
  height: 40px;
}

.avatar {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  border: 2px solid white;
}

.multi .avatar {
  height: 65%;
  width: 65%;
  bottom: 0;
  margin: 0;
}

.multi .avatar:first-of-type {
  top: 0;
  right: 0;
}

.clickable {
  cursor: pointer;
}
</style>

```

ゴメンナサイ、本来は `AvatarPic.vue` を修正して小さい表示に対応させるべきですが面倒なのでコピペしてしまっています。後でなおしましょう。

![スクリーンショット 2022-04-03 14.29.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/370053fa-7e9e-ae17-c50f-39d311231741.png)

チャット一覧を読み込んで、遷移できるようになりました。

次回はメインのチャット画面をやっていきましょう。