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

チャット作成機能を作っていきます。

# API

```python:server/models/chat.py
from datetime import datetime
from typing import List

from bson.objectid import ObjectId

from database import db


class Chat:
    _collection = db["chats"]

    def __init__(
        self,
        name: str,
        is_group_chat: bool,
        users: List,
        latest_message: str=None,
        created_at: datetime=None,
        updated_at: datetime=None
    ):
        self.name = name
        self.is_group_chat = is_group_chat
        self.users = users
        self.latest_message = latest_message
        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      

    def create(self):
        data = vars(self)
        data["users"] = [ObjectId(u) for u in data["users"]]
        return self._collection.insert_one(data)
    
    @classmethod
    def validate(cls, body, logged_in_user_id):
        if "users" not in body or not body["users"]:
            return "ユーザーは必須です"
            
        if logged_in_user_id not in body["users"]:
            return "自身を含まないチャットは作成できません"
        
        return None

```

```python:server/apis/chats.py
from flask import jsonify, request, session

from apis import error, login_required
from main import app
from models.chat import Chat

@app.route("/api/chats", methods=["POST"])
@login_required()
def create_chat():
    body = request.json
    
    error_message = Chat.validate(body, session["user"]["_id"])
    if error_message:
        return error(error_message)
    users = body["users"]
    chat = Chat(
        name=body.get("name"),
        is_group_chat=True,
        users=users
    )
    chat.create()
    return jsonify(vars(chat))

```

チャットとは、誰が参加しているかと名前を管理するような概念になります。

`is_group_chat`は、ユーザーが1対1で会話するためのデフォルトのチャット(プロフィール画面の封筒アイコンから呼び出せるようにあとでします)ではfalse, その他のチャットは(参加人数が2人でも)true、ということにします。

# 画面

```vue:client/src/views/NewChatView.vue
<template>
  <div class="uk-margin-right">
    <div>
      <h3 class="uk-margin-top">新しいメッセージ</h3>
    </div>
    <div class="search uk-search uk-search-default uk-width-expand">
      <span uk-search-icon></span>
      <input
        type="search"
        class="uk-search-input"
        placeholder="ユーザーを検索"
        v-model="query"
        @keyup="onKeyUp"
      />
    </div>
    <div class="selected uk-padding-small">
      <span
        class="uk-label"
        v-for="selected in selectedUsers"
        :key="selected._id"
      >
        {{ selected.display_name }}
        <button class="uk-close" uk-close @click="remove(selected)"></button>
      </span>
    </div>
    <div class="uk-text-center">
      <button
        class="uk-button uk-button-primary"
        :disabled="isInvalid"
        @click="submit"
      >
        チャットを作成
      </button>
    </div>
    <hr />
    <div>
      <user-panel
        v-for="user in users"
        :user="user"
        :key="user._id"
        :disableLink="true"
        @click="select(user)"
        class="clickable"
      ></user-panel>
    </div>
  </div>
</template>

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

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

export default {
  components: {
    UserPanel,
  },
  setup() {
    const store = useStore();
    store.dispatch("clearUsersForChat");
    const query = ref("");

    let timer;
    const onKeyUp = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (query.value.trim()) {
          store.dispatch("searchUsersForChat", { query: query.value });
        }
      }, 1000);
    };

    const selectedUsers = ref([]);

    const users = computed(() => {
      const ids = selectedUsers.value.map((u) => {
        return u._id;
      });
      ids.push(store.state.userLoggedIn._id);
      return store.state.chat.users.filter((u) => !ids.includes(u._id));
    });

    const select = (user) => {
      selectedUsers.value.push(user);
    };

    const remove = (user) => {
      const idx = selectedUsers.value.findIndex((u) => u._id === user._id);
      if (idx > -1) {
        selectedUsers.value.splice(idx, 1);
      }
    };

    const isInvalid = computed(() => selectedUsers.value.length <= 0);

    const router = useRouter();
    const submit = () => {
      const ids = [store.state.userLoggedIn._id];
      selectedUsers.value.forEach((u) => ids.push(u._id));
      store.dispatch("createChat", { users: ids }).then((resp) => {
        router.push({
          name: "chat",
          params: {
            chatId: resp._id,
          },
        });
      });
    };

    return {
      query,
      users,
      selectedUsers,
      onKeyUp,
      select,
      remove,
      isInvalid,
      submit,
    };
  },
};
</script>

<style scoped>
.selected {
  min-height: 50px;
}

.search input {
  border: none;
  border-bottom: 1px solid #e5e5e5;
}

.selected .uk-label {
  height: 30px;
  padding-top: 9px;
  margin: 3px;
}

.uk-close {
  color: white;
}

.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: [],
    },
    notifications: [],
    unreadNotificationCount: 0,
    replyTo: undefined,
    post: undefined,
  },
  getters: {},
  mutations: {
...
    setSearchUsersForChat(state, args) {
      state.chat.users = args.users;
    },
  },
  actions: {
...
    async clearUsersForChat({ commit }) {
      commit("setSearchUsersForChat", { users: [] });
    },
    async searchUsersForChat({ commit }, args) {
      return axios
        .get("/api/search/users", {
          params: {
            q: args.query,
          },
        })
        .then((resp) => {
          commit("setSearchUsersForChat", { users: resp.data });
        })
        .catch(defaultErrorHandler);
    },
    async createChat(_, args) {
      return axios
        .post("/api/chats", args)
        .then((resp) => Promise.resolve(resp.data))
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});

```

![スクリーンショット 2022-04-03 12.53.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/3066de97-c5a2-29ac-f3a2-3a68864dd510.png)

検索ボタンを押すのはダルそうなので、元の講座にならってキー入力から一定時間入力がなければ検索を実行するようにしています。

選択済みユーザーをlabelで表現しているのですが色もおかしいしアルファベットがすべて大文字に変換されるのであとでこれはなおしましょう。

チャットを作成できるようになったので今回はここまでにしましょう。