Twitterクローンを作ります #34 通知(2)

前回は通知情報を保存するところを実装したので、表示する部分をやっていきましょう。

元の講座では、"読みました"ボタンをクリックしたら既読としていましたがその操作はダルいので表示されたら既読としてしまいましょう。
また、画面左のメニューに未読の通知件数をバッジで表示したいので、未読件数が取得できるようなAPIも用意しましょう。実際は`GET /api/notifications` で返却した通知はすべてopenedをTrueに更新してしまう、?unread_only=trueが指定されたら opned を更新せず未読の通知のみ返却する、という挙動にしようと思います。

# API

前述の仕様でAPIを実装していきます。

```python:server/apis/notifications.py
import pymongo
from bson.objectid import ObjectId
from flask import jsonify, request, session

from apis import login_required
from main import app
from models.notification import Notification
from models.user import User


@app.route("/api/notifications", methods=["GET"])
@login_required()
def list_posts():
    user_id = ObjectId(session["user"]["_id"])
    condition = {"user_to": user_id}
    unread_only = request.args.get("unread_only", False)
    if unread_only:
        condition["opened"] = False

    results = []
    for notification in Notification._collection.find(
        condition, sort=[("created_at", pymongo.DESCENDING)]
    ):
        results.append(_populate(notification))

    if not unread_only and results:
        _mark_as_read(results)

    return jsonify(results)


def _populate(notification):
    user_from = User._collection.find_one({"_id": notification["user_from"]})
    if user_from:
        del user_from["hashed_password"]
        notification["user_from"] = user_from

    return notification


def _mark_as_read(notifications):
    ids = [n["_id"] for n in notifications]
    Notification._collection.update_many(
        {
            "_id": {
                "$in": ids
            }
        },
        {
            "$set": {
                "opened": True
            }
        }
    )

```

# 通知一覧画面

まずはルーターの設定

```javascript:client/src/router/index.js
...
  {
    path: "/notifications",
    name: "notifications",
    meta: { loginRequired: true },
    component: () => import("../views/NotificationsView.vue"),
  },
...

```

次にストア

```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: [],
    },
    notifications: [],
    unreadNotificationCount: 0,
    replyTo: undefined,
    post: undefined,
  },
  getters: {},
  mutations: {
...
    setNotifications(state, args) {
      state.notifications = args.notifications;
    },
    setUnreadNotificationCount(state, args) {
      state.unreadNotificationCount = args.count;
    },
  },
  actions: {
...
    async loadNotifications({ commit }) {
      return axios
        .get("/api/notifications")
        .then((resp) => {
          commit("setNotifications", { notifications: resp.data });
        })
        .catch(defaultErrorHandler);
    },
    async loadUnreadNotifications({ commit }) {
      return axios
        .get("/api/notifications?unread_only=true")
        .then((resp) => {
          commit("setUnreadNotificationCount", { count: resp.data.length });
        })
        .catch(defaultErrorHandler);
    },
  },
  modules: {},
});

```

ストアがごちゃごちゃしてきたのでモジュール化も検討していきましょう。

とりあえずNotificationsViewを作っていきます。


```vue:client/src/views/NotificationsView.vue
<template>
  <div>
    <h3 class="uk-margin-top">通知</h3>
    <div>
      <div v-for="n in notifications" :key="n._id">
        {{ n.notification_type }}
      </div>
    </div>
  </div>
</template>

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

export default {
  setup() {
    const store = useStore();
    const notifications = computed(() => store.state.notifications);

    onMounted(() => {
      store.dispatch("loadNotifications");
    });
    return {
      notifications,
    };
  },
};
</script>

```

通知取得のAPIを叩いて、レスポンスの一部を画面に表示できているので、表示部分をちゃんとすればよさそうです。

```vue:client/src/components/NotificationPanel.vue
<template>
  <div
    class="uk-flex uk-flex-row uk-flex-middle uk-margin-bottom panel"
    :class="{ active: !notification.opened }"
    @click="onClick"
  >
    <avatar-pic :user="notification.user_from" @click.stop></avatar-pic>
    <span class="uk-margin-left">{{ text }}</span>
  </div>
</template>

<script>
import { useRouter } from "vue-router";
import AvatarPic from "@/components/AvatarPic.vue";

export default {
  components: {
    AvatarPic,
  },
  props: {
    notification: Object,
  },
  setup(props) {
    const to = (() => {
      switch (props.notification.notification_type) {
        case "like":
        case "retweet":
        case "reply":
          return `/posts/${props.notification.params.post_id}`;
        case "follow":
          return `/profile/${props.notification.user_from._id}`;
      }
    })();

    const router = useRouter();

    const onClick = () => {
      router.push(to);
    };

    const text = (() => {
      const dispName = props.notification.user_from.display_name;
      switch (props.notification.notification_type) {
        case "like":
          return `${dispName} さんがあなたの投稿をいいねしました`;
        case "retweet":
          return `${dispName} さんがあなたの投稿をリツイートしました`;
        case "reply":
          return `${dispName} さんがあなたの投稿に返信しました`;
        case "follow":
          return `${dispName} さんがあなたをフォローしました`;
      }
    })();

    return {
      onClick,
      text,
    };
  },
};
</script>

<style scoped>
.panel {
  cursor: pointer;
}

.active {
  font-weight: bold;
}
</style>
```

```vue:client/src/views/NotificationViews.vue
<template>
  <div>
    <h3 class="uk-margin-top">通知</h3>
    <div>
      <notification-panel
        v-for="n in notifications"
        :key="n._id"
        :notification="n"
      >
      </notification-panel>
    </div>
  </div>
</template>

...
```

![スクリーンショット 2022-03-27 10.34.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/084d68e1-562f-2c16-c73b-4b617e0099e2.png)

表示できましたね。
次回は未読の通知の件数を左のメニューのアイコンに表示しましょう。