OneSignal を使って 以下機能を実装してみました。

  • WebPush 通知 Icon クリックで、購読の状態を切り替える。
  • Segment All で、APサーバーから通知する。

実装時に実施したことを記載します。

[TOC]


前提

バックエンド、フロントエンドには、それぞれ以下、ライブラリを使用しています。

バックエンド

Django 、 wagtail、 puput を使用しています。

  • Django (1.11.11)
  • wagtail (1.13.1)
  • puput (0.9.2.1)

フロントエンド

react と、 React Material Web Components を使用しています。

  • react@16.2.0
  • rmwc@1.1.2

WebPush 通知 Icon クリックで、購読の状態を切り替える。

OneSignal の デフォルトのベルを非表示にして、別途通知 Icon を使って、WebPush 購読の状態を切り替えるようにしてみました。

OneSingnal の初期化時に、ベルを非表示にする

notifyButton: { enable: false }設定することで、ベルが非表示になります。
特にそこまで気に入らないというわけでなければ、色を変えたり、表示位置を変更することはできるので、サイトの色に合わせて調整することはできるかと思います。
Custom Code Examples

  • main.js
        <script async src="https://cdn.onesignal.com/sdks/OneSignalSDK.js" />                        
        <script type="text/javascript">{`
            var OneSignal = window.OneSignal || [];
            OneSignal.push(["init", {
              appId: "${config.oneSignalAppId}",
              autoRegister: false,
              allowLocalhostAsSecureOrigin: true,
              notifyButton: {
                enable: false /* Set to false to hide */
              }
            }]);
        `}</script>
    

画面右上に、WebPush 通知 Icon を設置する

以下、画面上部の Navbar の JavaScript になります。
プログラムの下部に説明を記載します。

  • Navbar.js

    import React, {Component} from "react";
    import {Toolbar, ToolbarRow, ToolbarSection, ToolbarMenuIcon, ToolbarTitle, ToolbarIcon} from "rmwc";
    import config from "~/data/siteConfig";
    
    const getSubscriptionState = function() {
        var OneSignal = window.OneSignal || [];
        return Promise.all([
            OneSignal.isPushNotificationsEnabled(),
            OneSignal.isOptedOut(),
            OneSignal.getNotificationPermission()
        ]).then(function(result) {
            var isPushEnabled = result[0];
            var isOptedOut = result[1];
            var notificationPermission = result[2];
            return {
                isPushEnabled: isPushEnabled,
                isOptedOut: isOptedOut,
                notificationPermission: notificationPermission
            };
        });
    };
    
    export default class Navbar extends Component {
        constructor(props) {
            super(props);
            var self = this;
            self.state = {};
            self.getNotificationsIcon = self.getNotificationsIcon.bind(self);
            self.setCurrentNotificationState = self.setCurrentNotificationState.bind(self);
        }
        setCurrentNotificationState() {
            var self = this;
            getSubscriptionState().then(function(state) {
                self.setState({isPushEnabled: state.isPushEnabled});
                self.setState({isOptedOut: state.isOptedOut});
                self.setState({notificationPermission: state.notificationPermission});
            });        
        }
        componentDidMount() {
            var self = this;
            window.addEventListener("load", function() {
                self.setCurrentNotificationState();
                var OneSignal = window.OneSignal || [];
                /* This example assumes you've already initialized OneSignal */
                OneSignal.push(function() {
                    // If we're on an unsupported browser, do nothing
                    if (!OneSignal.isPushNotificationsSupported()) {
                        return;
                    }
                    OneSignal.on("subscriptionChange", function() {
                        self.setCurrentNotificationState();
                    });
                    OneSignal.on("notificationPermissionChange", function(permissionChange) {
                        var currentPermission = permissionChange.to;
                        if(currentPermission === "granted") {
                            // Subscription false > true の場合は、subscriptionChange が呼び出される
                            // self.setCurrentNotificationState(); は呼び出さなくてOK
                            OneSignal.setSubscription(true);
                        } else {
                            self.setCurrentNotificationState();
                        }
                    });
                });            
            });    
        }
        onNotificationButtonClicked(e) {
            var self = this;
            getSubscriptionState().then(function(state) {
                var OneSignal = window.OneSignal || [];
                if (state.isPushEnabled) {
                    /* Subscribed, opt them out */
                    OneSignal.setSubscription(false);
                } else {
                    if (state.isOptedOut) {
                        /* Opted out, opt them back in */
                        OneSignal.setSubscription(true);
                    } else {
                        /* Unsubscribed, subscribe them */
                        OneSignal.registerForPushNotifications();
                    }
                }
            }).then(function(){
                // true > false への変化の場合、subscriptionChange が呼び出されないので、ここで、    
                // self.setCurrentNotificationState(); を呼び出しておく。
                self.setCurrentNotificationState();
            });
            e.preventDefault();
        }
        getNotificationsIcon() {
            if(this.state.notificationPermission === "denied") {
                return "notifications_off";
            }
            if(this.state.isPushEnabled) {
                return "notifications_active";
            } else {
                if(this.state.isOptedOut) {
                    return "notifications_paused";
                }
            }
            return "notifications_none";
        }
        render() {
            return (
                <Toolbar>
                    <ToolbarRow>
                        <ToolbarSection alignStart>
                            <ToolbarMenuIcon use="menu" onClick={this.props.toggle}/>
                            <ToolbarTitle>{config.siteTitle}</ToolbarTitle>
                        </ToolbarSection>
                        <ToolbarSection alignEnd>
                            <ToolbarIcon tag="a" href={config.siteUrl + "/feed"} strategy="ligature" use="rss_feed"/>
                            <ToolbarIcon tag="a" onClick={e => this.onNotificationButtonClicked(e)} strategy="ligature" use={this.getNotificationsIcon()} />
                        </ToolbarSection>
                    </ToolbarRow>
                </Toolbar>
            );
        }
    }
    

  • 説明

    • WebPush ToolbarIcon について

      <ToolbarIcon tag="a" onClick={e => this.onNotificationButtonClicked(e)} strategy="ligature" use={this.getNotificationsIcon()} />
      
      WebPush Iconになります。ToolbarIcon は rmwc/toolbar.md at master · jamesmfriedman/rmwc説明が記載されています。
      use 属性で、getNotificationsIcon ファンクションを指定して、通知の状態で、Icon を切り替えています。
      onClick 属性で、onNotificationButtonClicked ファンクションを指定して、通知の状態を切り替えています。

    • componentDidMount について
      コンポーネントの Mount 完了時に、EventListener を追加しています。タイミングが、load なのは、ComtentDomLoadedタイミングではOneSignal の Javascript が読み込まれておらず、未定義エラーになったためです。
      EventListener では、OneSignal の subscriptionChange と、notificationPermissionChange追加しています。
      subscriptionChange は、通知購読の状態が変更された場合に呼び出される処理で、notificationPermissionChange 通知のパーミッションの変更時に呼び出される処理になります。subscriptionChange false > trueケースのみ呼び出される処理のようで、true > falseケースでも行いたい処理がある場合は、明示的に呼び出す必要があります。
      notificationPermissionChange では、パーミッションが granted場合、OneSignal.setSubscription(true);呼び出していますが、これによって、false > true となり、subscriptionChange起動し、Icon の表示が切り替わります。

    • getSubscriptionState について
      Custom Code Examples記載のあるプログラムを参考にしつつ、OneSignal.getNotificationPermission()追加して、現状の通知パーミッションの状態を取得するようにしました。
      これは、通知 Icon の切り替えで、通知の状態も加味したかったため取得しています。

    • 通知 Icon について
      Icon は以下、4つの状態があります。

      1. notifications_off 通知は拒否された場合に、この Icon 表示になります。

      2. notifications_active
        通知は許可されていて、購読も許可されている場合、この Icon 表示になります。

      3. notifications_paused
        通知は許可されていて、購読は不許可(停止した)に設定されている場合、この Icon 表示になります。

      4. notifications_none
        通知に対するアクションをユーザ が何もとっていないか、画面上のオペレーション以外の方法で WebPush 通知の状態を編集した場合にこの Icon 表示になります。


Segment All で、APサーバーから通知する。

記事の更新があった場合、Segment All対して、Webpush 通知を送信する Django コマンドを作成しました。
実装には、joaobarbosa/onesignal-python: Python client for OneSignal push notification serviceいう OneSignal の python クライアントがありましたので、それを使用しました。

設定方法

  • インストール

    python3 -m pip install onesignal-python
    

  • onesignalclient settings.py に追加する

    INSTALLED_APPS = [
        ....    
        'onesignalclient'
    ]
    

  • settings.py に、ONESIGNAL_REST_API_KEY と、ONESIGNAL_APP_ID を追加する
    OneSignal の アプリケーションを作成すると、ONESIGNAL APP ID と、REST API KEY が払い出されます。
    払い出された、ID と KEY を settings.py に追加します。

    ONESIGNAL_APP_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ONESIGNAL_REST_API_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    
    これは、後述する Django コマンドで使用しています。

Django コマンド

新着投稿を WebPush する Django コマンドを作成しました。

  • send_notigications.py

    # -*- coding: utf-8 -*-                                                                                                                                                             
    from __future__ import print_function
    from django.core.management.base import BaseCommand
    from requests.exceptions import HTTPError
    from onesignalclient.app_client import OneSignalAppClient
    from onesignalclient.notification import Notification
    from puput.models import EntryPage
    from django.conf import settings
    import datetime
    
    class Command(BaseCommand):
    
        def handle(self, **options):
            now = datetime.datetime.now()
            entries = EntryPage.objects.filter(date__gte=now - datetime.timedelta(hours=4))
            if entries:
                entry = entries.first()
                content = entry.title
                # Init the client
                client = OneSignalClient(app_id=settings.ONESIGNAL_APP_ID, app_api_key=settings.ONESIGNAL_REST_API_KEY)
                # Creates a new notification
                notification = Notification(settings.ONESIGNAL_APP_ID, Notification.SEGMENTS_MODE)
                notification.contents = {"en": content}
                notification.headings = {"en": "New article"}
                option = {}
                option.update({"included_segments" : ["All"]});
    
                try:
                    # Sends it!
                    result = client.create_notification_with_option(notification, option)
                except HTTPError as e:
                    result = e.response.json()
    
    class OneSignalClient(OneSignalAppClient):
    
        def create_notification_with_option(self, notification, option):
            """
            Creates a new notification.
            :param notification: onesignalclient.notification.Notification object
            """
            payload = notification.get_payload_for_request()
            payload.update(option)
            print(payload)
            return self.post(self._url(self.ENDPOINTS['notifications']),
                             payload=payload)
    

  • 説明

    • Bodyに設定している文字列について WebPush は スペースの都合なのかあまり長い文字列を body 部に設定できず、ブラウザにより設定できる文字数も異なるようです。
      このため、1つの記事のタイトルを取り出して、body に設定するようにしました。

    • OneSignalClientについて
      nesignal-python の、OneSignalAppClient は、セグメントに対する送信ができないため、OneSignalAppClient を拡張して、Segment に対して、送信できるようにしました。

    • コマンドのスケジューリングについて
      実行タイミングの4時間前までに作成された記事を取得して、WebPush 通知を行うようにしたため、crontab でのスケジュール実行も4時間 ごとに実装するようにしました。

    ライブラリを使わないで実装する

    Create notification Python で実装された クライアントのサンプルがあります。ライブラリを使わずに、実装しても、それほど複雑にはならなそうだったので、最終的にライブラリの使用しない実装に書き換えました。

  • send_notigications.py
    余計な継承クラスがない分、ライブラリを使用しないほうが、シンプルですね。

    # -*- coding: utf-8 -*-                                                                                                                                                             
    from __future__ import print_function
    from django.core.management.base import BaseCommand
    from requests.exceptions import HTTPError
    from puput.models import EntryPage
    from django.conf import settings
    import datetime
    import requests
    import json
    
    
    class Command(BaseCommand):
    
        def handle(self, **options):
            now = datetime.datetime.now()
            entries = EntryPage.objects.filter(date__gte=now - datetime.timedelta(hours=4))
            if entries:
                entry = entries.first()
                content = entry.title
                header = {"Content-Type": "application/json; charset=utf-8",
                          "Authorization": "Basic " + settings.ONESIGNAL_REST_API_KEY}
    
                payload = {"app_id": settings.ONESIGNAL_APP_ID,
                           "included_segments": ["All"],
                           "contents": {"en": content},
                           "headings": {"en": "New article"}
                           }
    
                req = requests.post("https://onesignal.com/api/v1/notifications", headers=header, data=json.dumps(payload))
    


参考

コメント