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

チャットの情報とメッセージをAPIで取得し、チャット画面に表示していきます。

# API

まずはチャットの1件取得APIです。

```python:server/models/chat.py
    @classmethod
    def get_chat(cls, chat_id):
        pipelines = [
            {
                "$match": {
                    "_id": ObjectId(chat_id),
                }
            },
            {
                "$lookup": {
                    "from": "users",
                    "localField": "users",
                    "foreignField": "_id",
                    "as": "users",
                }
            },
        ]

        result = list(cls._collection.aggregate(pipelines))
        if len(result):
            return result[0]
        else:
            return None
```

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

@app.route("/api/chats/<chat_id>", methods=["GET"])
@login_required()
def get_chat(chat_id):
    chat = Chat.get_chat(chat_id)
    if not chat:
        return error("チャットが存在しないか、参加していないチャットです")
    user_id = session["user"]["_id"]
    if not [u for u in chat.get("users", []) if u["_id"] == ObjectId(user_id)]:
        return error("チャットが存在しないか、参加していないチャットです")
    return jsonify(chat)
```

# 画面

メッセージはややこしいのでコンポーネント化します。

```vue:client/src/components/ChatMessage.vue
<template>
  <div class="message uk-flex" :class="{ mine: isMine, theirs: !isMine }">
    <div class="image">
      <img v-if="!isMine && isLast" :src="imageUrl" />
    </div>
    <div class="container uk-flex uk-flex-column">
      <span
        class="sender uk-text-muted uk-text-small"
        v-if="!isMine && isFirst"
      >
        {{ message.sender.display_name }}
      </span>
      <span class="body uk-text-small">
        {{ message.content }}
      </span>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    message: Object,
    isMine: Boolean,
    isFirst: Boolean,
    isLast: Boolean,
  },
  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;
    })(props.message.sender);
    return {
      imageUrl,
    };
  },
};
</script>

<style scoped>
.message {
  align-items: flex-end;
  padding-bottom: 4px;
}

.message.mine {
  flex-direction: row-reverse;
}

.container {
  max-width: 55%;
}

.body {
  background-color: #f1f0f0;
  padding: 6px 12px;
  border-radius: 18px;
}

.mine .body {
  background-color: #1fa2f1;
  color: white;
}

.image {
  height: 24px;
  width: 24px;
  margin-right: 7px;
}

.image img {
  height: 100%;
  border-radius: 50%;
  vertical-align: bottom;
}
</style>
```

コンポーネントに切り出した部分を削除しつつ、表示時に必要なデータをロードします。

```vue:client/src/views/ChatView.vue
<template>
  <div class="uk-margin-right uk-flex uk-flex-column uk-height-1-1">
    <div>
      <h3 class="uk-margin-top">チャット</h3>
    </div>
    <div class="uk-text-bold">チャットのタイトル</div>
    <hr class="top-hr" />
    <div class="uk-flex-1 uk-flex uk-flex-column content">
      <chat-message
        v-for="(message, idx) in messages"
        :key="message._id"
        :message="message"
        :isMine="isMine(message)"
        :isFirst="isFirst(message, idx)"
        :isLast="isLast(message, idx)"
      ></chat-message>
    </div>
    <hr class="bottom-hr" />
    <div class="uk-flex uk-flex-row uk-flex-middle uk-margin-bottom">
      <input
        type="text"
        class="uk-input uk-flex-1 uk-margin-right"
        v-model="text"
        @keypress.prevent.enter.exact="enableSubmit"
        @keyup.prevent.enter.exact="submit"
      />
      <span
        uk-icon="icon: comment; ratio: 1.5"
        @click="onSendClick"
        :class="{ clickable: isValid }"
        :disabled="!isValid"
      ></span>
    </div>
  </div>
</template>

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

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

export default {
  components: {
    ChatMessage,
  },
  setup() {
    const store = useStore();
    const route = useRoute();

    const text = ref("");

    const canSubmit = ref(false);

    const enableSubmit = () => {
      canSubmit.value = true;
    };

    const _post = () => {
      store
        .dispatch("sendMessage", {
          content: text.value,
          sender: store.state.userLoggedIn._id,
          chat: route.params["chatId"],
        })
        .then(() => (text.value = ""));
    };

    const submit = () => {
      if (!canSubmit.value || !text.value.trim()) {
        return;
      }
      _post();
      canSubmit.value = false;
    };

    const onSendClick = () => {
      if (!text.value.trim()) {
        return;
      }
      _post();
      canSubmit.value = false;
    };

    const isValid = computed(() => {
      return Boolean(text.value);
    });

    watch(
      () => route.params,
      (newParams) => {
        store.dispatch("loadChat", { chat: newParams["chatId"] });
        store.dispatch("loadMessages", { chat: newParams["chatId"] });
      },
      {
        immediate: true,
      }
    );

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

    const isMine = (message) => {
      return message.sender._id === store.state.userLoggedIn._id;
    };

    const isFirst = (message, idx) => {
      if (idx === 0) {
        return true;
      }
      const prev = messages.value[idx - 1];
      return prev.sender._id != message.sender._id;
    };

    const isLast = (message, idx) => {
      if (idx === messages.value.length - 1) {
        return true;
      }
      const next = messages.value[idx + 1];
      return next.sender._id != message.sender._id;
    };

    return {
      messages,
      text,
      enableSubmit,
      submit,
      onSendClick,
      isValid,
      isMine,
      isFirst,
      isLast,
    };
  },
};
</script>

<style scoped>
.content {
  overflow-y: auto;
  margin: 0;
  padding: 5px;
}

.top-hr {
  margin-bottom: 0;
}

.bottom-hr {
  margin-top: 0;
}

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

ストアも対応させましょう。

```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: [],
      messages: [],
    },
    notifications: [],
    unreadNotificationCount: 0,
    replyTo: undefined,
    post: undefined,
  },
  getters: {},
  mutations: {
...
    setMessages(state, args) {
      state.chat.messages = args.messages;
    },
  },
  actions: {
...
    async loadChat(_, args) {
      return axios
        .get(`/api/chats/${args.chat}`)
        .then((resp) => {
          // TODO
          console.log(resp.data);
        })
        .catch(defaultErrorHandler);
    },
    async loadMessages({ commit }, args) {
      return axios
        .get(`/api/chats/${args.chat}/messages`)
        .then((resp) => {
          console.log(resp.data);
          commit("setMessages", { messages: resp.data });
        })
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});

```

![スクリーンショット 2022-04-03 22.04.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/80674aa9-e864-1ef8-0bdc-b62499d2720f.png)


投稿したタイミングでは表示は変わりませんが、その後ページをリロードすると上記のようにメッセージが表示されるようになりました。

今回はここまでにしましょう。