LoginSignup
0
0

More than 1 year has passed since last update.

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

Posted at

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

API

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

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
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)

画面

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

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>

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

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>

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

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0