📆

[Mattermost Integrations] Plugin (Webapp 1)

2021/06/19に公開

Mattermost 記事まとめ: https://blog.kaakaa.dev/tags/mattermost/

本記事について

Mattermost の統合機能アドベントカレンダーの第 20 日目の記事です。

本記事では、Mattermost 上の様々な操作に対応した処理を追加できる Mattermost Plugin のWebappサイドの機能を紹介していきます。

記事が長いので 2 日に分けて紹介していきます。それでも長いです。

Mattermost Plugin についての公式ドキュメントは下記になります。
https://developers.mattermost.com/extend/plugins/overview/

サンプルコードは下記リポジトリにコミットしています。
https://github.com/kaakaa/mattermost-plugin-api-sample

Mattermost Plugin (Webapp) について

Mattermost Plugin (Webapp) の本体について紹介します。

Mattermost のWebappサイドの Plugin を実装する場合、プラグイン本体はregistrystoreを引数にとるinitializeメソッドを持つクラスです。registryは Mattermost Plugin (Webapp)の機能を呼び出すためのメソッドを持つオブジェクトで、storeは Mattermost のデータにアクセスするためのインターフェースです。

公式ドキュメントでは、以下のようなPluginClassが紹介されています。
https://developers.mattermost.com/extend/plugins/webapp/reference/

class PluginClass {
    /**
    * initialize is called by the webapp when the plugin is first loaded.
    * Receives the following:
    * - registry - an instance of the registry tied to your plugin id
    * - store - the Redux store of the web app.
    */
    initialize(registry, store)

    /**
    * uninitialize is called by the webapp if your plugin is uninstalled
    */
    uninitialize()
}

このPluginClassのインスタンスをwindowオブジェクトが持つregisterPlugin関数に Plugin ID と共に与えることで、Plugin が有効になります。

window.registerPlugin("myplugin", new PluginClass());

Webapp Plugin API

registerRootComponent

registerRootComponentは、画面全体に表示されるモーダルの React Component を登録する API です。Component が登録されると、Component を一意に識別するための ID が返却されます。

ここで登録した Root Component は、登録するだけで表示されるわけではなく、別のアクションから起動されることで表示されます。

登録された Component で使用できる props は特にありません。channel_controller.jsx

今回は、チャンネルのヘッダー部分にボタンを追加するregisterChannelHeaderButtonAction API を使って Root Component を表示する例を以下に示します。全てのプログラムを記述すると非常に長くなってしまうため、Redux によるデータのやりとりなど部分については本記事では割愛します。プログラム全体について知りたい場合は、kaakaa/mattermost-plugin-api-sample
また、Mattermost 公式チームにより作成されているデモ用プラグインmattermost-plugin-demoの実装も参考になります。

...

import {openRootModal} from './actions'
import reducer from './reducer';

import Root from './components/root';

...

export default class Plugin {
    initialize(registry, store) {
        // (1) Root Componentの登録
        registry.registerRootComponent(Root)

        // (2) Root Componentを呼び出すアクションの登録
        registry.registerChannelHeaderButtonAction(
            channelHeaderButtonIcon,
            () => store.dispatch(openRootModal()),
            "Open Root modal","Open Root modal"
        );

        registry.registerReducer(reducer);
    }
}

上記の例では、(1)で Root Component となる React Component を登録し、(2)で Root Component を表示するアクションをチャンネルヘッダーボタンに与えています。(2)store.dispatch(openRootModal())が Root Component を表示する処理ですが、これは Root Component を表示する state を流す処理になります。

Root Component は以下のようなコードであり、visible = trueとなることで画面に表示されます。

import React from "react";

const Root = ({ visible, close }) => {
  if (!visible) {
    return null;
  }

  const style = getStyle();

  return (
    <div style={style.backdrop} onClick={close}>
      <div style={style.modal}>
        <p>Root modal</p>
      </div>
    </div>
  );
};

Root.propTypes = {
  visible: PropTypes.bool.isRequired,
  close: PropTypes.func.isRequired,
};

const getStyle = () => ({
  backdrop: {
    position: "absolute",
    display: "flex",
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    backgroundColor: "rgba(0, 0, 0, 0.50)",
    zIndex: 2000,
    alignItems: "center",
    justifyContent: "center",
  },
  modal: {
    height: "300px",
    width: "500px",
    padding: "1em",
    color: "black",
    backgroundColor: "white",
  },
});

export default Root;

Root Component は React Component のため、様々な要素を表示することができますが、基本的には通知のために利用し、ユーザーに入力を促すようなユースケースの場合はInteractive Dialogを使用すべきだと思います。

registerPopoverUserAttributesComponent

registerPopoverUserAttributesComponentは、ユーザーアイコンをクリックすると表示される popover 内に表示される Component を登録します。

Mattermost 外からユーザーの情報を取得して表示する際に使用できます。所属の名称がコロコロ変わる会社などで LDAP から所属を引いて表示しておくと便利です。

登録した Component は以下の props を受け取れます。profile_popover.jsx

  • user: popover で表示しているユーザーのオブジェクト
    • アクセスできるのはUserモデルで定義されている情報になると思います
    • Userモデルでomitemptyとなっているものは空の場合除外されるため、値がある場合のみアクセスできます
  • hide: popover を閉じる関数
  • status: popover で表示しているユーザーの status
    • online, offlineなどの単なる文字列のようです

ここでは、固定文字列を表示する例を示します。

index.js
...

import UserAttributes from './components/user_attributes'

...

export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // (1) User Popoverに説明を追加するComponentの登録
		registry.registerPopoverUserAttributesComponent(UserAttributes)
		...
    }
}
components/user_attributes.jsx
import React from 'react';
import PropTypes from 'prop-types';

const UserAttributes = ({user, hide, status}) => {
    console.log("user", user.id);
    return (
        <div>
            <p>{'UserId: ' + user.id}</p>
        </div>
    );
}

UserAttributes.propTypes = {
    user: PropTypes.object.isRequired,
    hide: PropTypes.func.isRequired,
    status: PropTypes.object.isRequired,
};

export default UserAttributes;

registerPopoverUserActionsComponent

registerPopoverUserActionsComponentは、ユーザーアイコンをクリックすると表示される popover 内に表示される Component を登録します。registerPopoverUserAttributesComponentと似ていますが、表示される位置が異なるだけの違いだと思います。

登録した Component は、registerPopoverUserAttributesComponentと同じく以下の props を受け取れます。profile_popover.jsx

  • user: popover で表示しているユーザーのオブジェクト
  • hide: popover を閉じる関数
  • status: popover で表示しているユーザーの status

registerPopoverUserActionsComponentで、RootComponent を表示する例は以下のようになります。(処理を一部割愛しているため、プログラム全体は https://github.com/kaakaa/mattermost-plugin-api-sample から確認してください)

index.js
...
import UserAction from './components/user_action';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // User Popoverにアクションを追加するComponentの登録
		registry.registerPopoverUserActionsComponent(UserAction)
		...
	}
}
components/user_action/user_action.jsx
import React from 'react';
import PropTypes from 'prop-types';

const UserActionComponent = ({openRootModal, user, hide, status}) => {
	const onClick = () => {
		openRootModal();
		hide();
	};
    return (
        <div>
            <p>User Action <button onClick={onClick}>Action</button></p>
        </div>
    )
};

UserActionComponent.PropTypes = {
	openRootModal: PropTypes.func.isRequired,
    user: PropTypes.object.isRequired,
    hide: PropTypes.func.isRequired,
    status: PropTypes.object.isRequired,
};

export default UserActionComponent;

registerLeftSidebarHeaderComponent

registerLeftSidebarHeaderComponentは、Mattermost 画面の左サイドバーに表示する Component を登録します。

登録された Component で使用できる props は特にありません。sidebar.tsx

常に表示されている部分のため、職場の室温や CO2 濃度などを表示するなどの利用方法があるかと思います。

index.js
...
import LeftSidebarHeader from './components/left_sidebar_header';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // User Popoverにアクションを追加するComponentの登録
		registry.registerLeftSidebarHeaderComponent(LeftSidebarHeader)
		...
	}
}
components/left_sidebar_header.jsx
import React from 'react';

const LeftSidebarHeaderComponent = () => {
	const style = {
		color: 'white',
	};
    return (
        <div style={style}>
            <p>left sidear header</p>
        </div>
    );
}

export default LeftSidebarHeader;

registerBottomTeamSidebarComponent

registerBottomTeamSidebarComponentは、チーム選択サイドバーの下部に表示される Component を登録します。1つのチームにしか参加していない場合、チーム選択サイドバー自体が表示されないため、ここで登録した Component も表示されません。

登録された Component で使用できる props は特にありません。legacy_team_sidebar_controller.tsx

index.js
...
import BottomTeamSidebar from './components/bottom_team_sidebar';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // User Popoverにアクションを追加するComponentの登録
		registry.registerLeftSidebarHeaderComponent(BottomTeamSidebar)
		...
	}
}
components/bottom_team_sidebar.jsx
import React from 'react';

const BottomTeamSidebarComponent = () => (
    <>
        <a href="https://github.com/">
            <i
                className='icon fa fa-github'
                style={{color: 'white'}}
            />
        </a>
    </>
);

export default BottomTeamSidebarComponent;

registerLinkTooltipComponent

registerLinkTooltipComponentは、リンクを hover した時に表示される tooltip の Component を登録します。

登録した Component は以下の props を受け取れます。link_tooltip.tsx

  • href: hover しているリンクの URL
  • show: tooltip が表示されているかどうかのフラグ

以下の例では単に hover しているリンクの URL を表示しているだけですが、リンク先から情報を取得して表示するようなこともできます。

index.js
...
import LinkTooltip from './components/link_tooltip';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // User Popoverにアクションを追加するComponentの登録
		registry.registerLinkTooltipComponent(LinkTooltip)
		...
	}
}
components/link_tooltip.jsx
import React from 'react';
import PropTypes from 'prop-types';

const LinkTooltipComponent = ({href}) => {
    return (
        <>
            <p style={{
                backgroundColor: 'white',
                color: 'black',
                padding: '25px',
                border: '1px solid red',
            }}>
                {href}
            </p>
        </>
    )
}

LinkTooltipComponent.propTypes = {
    href: PropTypes.string.isRequired,
};

export default LinkTooltipComponent;

registerChannelHeaderButtonAction

registerChannelHeaderButtonActionは、registerRootComponentのところでも使用しましたが、チャンネルのヘッダ部分にアクション付きのボタンを登録します。

registerChannelHeaderButtonActionは、(icon, action, dropdownText, tooltipText)の4つの引数を取ります。

  • icon: ボタンのアイコンを表す React Element
  • action: ボタンがクリックされたときに実行されるアクション
    • ボタンが押されたチャンネルの情報を持つchannelと、ボタンを押したユーザーのchannelMemberを引数に取ることができます
  • dropdown_text: ボタンが複数登録されると一つのアイコンにまとめられ、ドロップダウンメニューから選ぶ形式になりますが、その時のドロップダウンメニューに表示される文字列
  • tooltip_text: ボタンを hover した時に tooltip として表示される文字列

ボタン形式

ドロップダウン形式

ここでは、先ほどの Root Comopnent を開く Button と、クリックすると投稿を作成する Button の 2 つの Button を表示する例を示します。投稿の作成はmattermost-reduxを使って投稿を作成する actions を作成し、Channel Header Button の action から呼び出しています。

movie

index.js
import {openRootModal, createPluginPost} from './actions';
import reducer from './reducer';
import Root from './components/root';

...

export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
        // Root Componentの登録
        const rootComponentId = registry.registerRootComponent(Root)

        // Root Componentを呼び出すアクションの登録
        registry.registerChannelHeaderButtonAction(
            () => (<i className='icon fa fa-commenting-o' style={{fontSize: '15px', position: 'relative', top: '-1px'}}/>),
            () => store.dispatch(openRootModal()),
            "Open Root modal","Open Root modal"
		);

		...

        // 投稿を作成するチャンネルヘッダボタンを登録
        registry.registerChannelHeaderButtonAction(
            () => (<i className='icon fa fa-commenting-o' style={{fontSize: '15px', position: 'relative', top: '-1px'}}/>),
            (channel, channelMembers) => store.dispatch(createPluginPost(channel.id)),
            "Create Sample Post", "Create Sample Post"
        );

        registry.registerReducer(reducer);
    }
}
actions.js
import {createPost} from 'mattermost-redux/actions/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';

...

export function createPluginPost(channelId) {
    return async (dispatch, getState) => {
        const state = getState();
        const userId = getCurrentUserId(state)
        const post = {
            channel_id: channelId,
            user_id: userId,
            message: "Post from webapp plugin",
        }
        return await dispatch(createPost(post));
    }
}

registerPostTypeComponent

registerPostTypeComponentは、特定の type を持つ投稿をレンダリングする時に使用される Component を登録します。投稿(Post)に独自の type を指定する場合、その type はcustom_で始まる必要があります。

登録した Component は以下の props を受け取れます。

type にcustom_sample_postを持つ投稿が作成された場合、背景色が赤の投稿をレンダリングする例を以下に示します。今回は、Incoming Webhook で投稿を作成する際のパラメータとしてcustom_sample_postの独自 type を指定しています。

curl \
  -H "Content-Type: application/json" \
  -d '{"type": "custom_sample_post", "text": "Sample Message", "props": {"card": "## Sample\n* foo\n* bar"}}'  \
  http://localhost:8065/hooks/ucw5qjw86jgeum77o1uw8197jr

index.js
...
import CustomPost from './components/custom_post';

export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // type: custom_sample_post を持つ投稿をレンダリングするComponentの登録
		registry.registerPostTypeComponent('custom_sample_post', CustomPost);
	}
}
...
components/custom_post.jsx
import React from 'react';
import PropTypes from 'prop-types';

const {formatText, messageHtmlToComponent} = window.PostUtils;

const CustomPostComponent = ({post, theme}) => {
    const formattedText = messageHtmlToComponent(formatText(post.message));

    console.log('theme', theme);
    return (
        <div style={{backgroundColor: '#ffcccc'}}>
            {formattedText}
            <pre>
                {JSON.stringify(post.props, null, 4)}
            </pre>
        </div>
    )
}

CustomPostComponent.propTypes = {
    post: PropTypes.object.isRequired,
    theme: PropTypes.object.isRequired,
};

export default CustomPostComponent;

独自の type を指定した投稿を作成した後に Plugin を無効にするなど、type に対応した Component が登録されていない場合は、通常の投稿と同じようにレンダリングされます。

registerPostCardTypeComponent

registerPostCardTypeComponentは、特定の type を持つ投稿のcard要素をレンダリングする時に使用される Component を登録します。投稿(Post)に独自の type を指定する場合、その type はcustom_で始まる必要があります。

登録した Component は以下の props を受け取れます。

type にcustom_sample_cardを持つ投稿が作成された場合、背景色が青のカード要素を持つ投稿をレンダリングする例を以下に示します。custom_sample_cardという独自 type は Incoming Webhook のパラメータとして指定しています。

curl \
  -H "Content-Type: application/json" \
  -d '{"type": "custom_sample_card", "text": "Sample Message", "props": {"card": "## Sample\n* foo\n* bar"}}'  \
  http://localhost:8065/hooks/ucw5qjw86jgeum77o1uw8197jr

作成する投稿にprops.cardプロパティがない場合、card要素を表示するためのiボタンが表示されないためカード要素を表示することができません。必ずprops.card要素を指定する必要があります。

index.js
...
import CustomCard from './components/custom_card';

export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // type: custom_sample_card を持つ投稿をレンダリングするComponentの登録
        registry.registerPostCardTypeComponent('custom_sample_card', CustomCard);
	}
}
...
components/custom_card.jsx
import React from 'react';
import PropTypes from 'prop-types';

const {formatText, messageHtmlToComponent} = window.PostUtils;

const CustomCardComponent = ({post, theme}) => {
    const formattedText = messageHtmlToComponent(formatText(post.message));

    console.log('theme', theme);
    return (
        <div style={{backgroundColor: '#ccccff'}}>
            {formattedText}
            <pre>
                {JSON.stringify(post.props, null, 4)}
            </pre>
        </div>
    )
}

CustomCardComponent.propTypes = {
    post: PropTypes.object.isRequired,
    theme: PropTypes.object.isRequired,
};

export default CustomCardComponent;

独自の type を指定した投稿を作成した後に Plugin を無効にするなど、type に対応した Component が登録されていない場合は、通常の投稿と同じようにレンダリングされます。

registerPostWillRenderEmbedComponent

registerPostWillRenderEmbedComponentは、投稿内で最初に出現する URL の内容をプレビュー表示する部分のコンポーネントを登録します。OpenGraph の情報を表示するアレです。

registerPostWillRenderEmbedComponentは 3 つの引数を取ります。

  • match: ここで指定したコンポーネントでプレビュー要素をレンダリングするかどうかを決定するための関数。プレビュー表示対象の URL や種別を使い、trueを返すとここで指定したコンポーネントを使用する。
  • component: プレビュー要素をレンダリングするコンポーネント
  • toggleable: プレビュー要素を折り畳み可能にするかどうかのフラグ

また、登録したComponentは以下の props を受け取れます。

  • embed: 投稿の内容 post.go
    • type: urlから取得できるプレビューの種別。opengraphimageなどの値が入る
    • url: 投稿ないで最初に出現する URL
    • data: urlから取得できるプレビュー内容に関する情報が格納されるらしいが、試している中では値が取得できなかった

https://github.com/mattermostで始まる URL が指定された場合に、青い背景色のプレビューをレンダリングする例を以下に示します。

index.js
...
import CustomEmbed from './components/custom_embed';

export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // 投稿に含まれるURLのプレビューをレンダリングするComponentの登録
        registry.registerPostWillRenderEmbedComponent(
            (embed) => embed.url && embed.url.startsWith(`https://github.com/mattermost/`),
            CustomEmbed,
            true
        );
    }
}
...
components/custom_embed.jsx
import React from 'react';
import PropTypes from 'prop-types';

const CustomEmbedComponent = ({embed}) => {
    const {type, url, data} = embed;
    return (
        <div style={{backgroundColor: '#ccffcc'}}>
            <p>{type}</p>
            <p>{url}</p>
            <pre>
                {JSON.stringify(data, null, 4)}
            </pre>
        </div>
    )
}

CustomEmbedComponent.propTypes = {
    embed: PropTypes.shape ({
        type: PropTypes.string.isRequired,
        url: PropTypes.string.isRequired,
        data: PropTypes.object.isRequired,
    })
};

export default CustomEmbedComponent;

以下のリポジトリのoEmbed-pluginブランチに、Mattermost 公式チームによるregisterPostWillRenderEmbedComponentを使用したプラグインの実装が残っています。(作りかけで止まっているようですが)
https://github.com/mattermost/mattermost-plugin-oembed/tree/oEmbed-plugin

registerMainMenuAction

registerMainMenuActionは、Mattermost のメインメニュー部分に独自のメニューを追加します。

registerPostWillRenderEmbedComponentは 3 つの引数を取ります。

  • text: メインメニューに表示される文字列か React Element を指定します
  • action: メインメニューが選択された際に実行されるアクションを登録します
  • mobileIcon: モバイルアプリで表示されるアイコンを登録します(が、モバイルアプリだとメインメニュー表示されない...?)

モーダルを開くアクションが登録されたメインメニューを追加する例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // メインメニューを追加する
        registry.registerMainMenuAction(
            'Sample Main Menu',
            () => store.dispatch(openRootModal()),
            () => (<i className='icon fa fa-plug' style={{fontSize: '15px', position: 'relative', top: '-1px'}}/>)
        );
    }
}
...

registerChannelHeaderMenuAction

registerChannelHeaderMenuActionは、チャンネル名の部分をクリックした時に表示されるメニューに独自のメニューを追加します。

registerPostWillRenderEmbedComponentは 2 つの引数を取ります。

  • text: メニューに表示される文字列か React Element
  • action: メニューが選択された際に実行されるアクション。実行されたチャンネルのチャンネル ID が渡されます。

モーダルを開くアクションが登録されたチャンネルヘッダーメニューを追加する例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // チャンネル名をクリックした際に表示されるメニューに独自メニューを追加する
        registry.registerChannelHeaderMenuAction(
            <i className='icon fa fa-plug' style={{fontSize: '15px', position: 'relative', top: '-1px'}}>{'Sample Menu'}</i>,
            (chnnelId) => store.dispatch(openRootModal())
        );
    }
}
...

registerPostDropdownMenuAction

registerPostDropdownMenuActionは、投稿に対するドロップダウンメニューに独自のメニューを追加します。

registerPostDropdownMenuActionは 3 つの引数を取ります。

  • text: メニューに表示される文字列か React Element
  • action: メニューが選択された際に実行されるアクション。メニューが実行された投稿の PostID が渡されます。
  • filter: メニューを表示するかどうかを決定する関数。実行された投稿の PostID が渡されます。

モーダルを開くアクションが登録されたドロップダウンメニューを追加する例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // 投稿に対するドロップダウンメニューに独自メニューを追加する
        registry.registerPostDropdownMenuAction(
            <i className='icon fa fa-plug' style={{fontSize: '15px'}}>{'Sample Post Menu'}</i>,
            (postId) => store.dispatch(openRootModal()),
            (postId) => { return true; }
        );
    }
}
...

registerPostDropdownSubMenuAction

registerPostDropdownSubMenuActionは、投稿に対するドロップダウンメニューにサブメニュー付きの独自のメニューを追加します。

まず、registerPostDropdownSubMenuActionは 3 つの引数を取ります。

  • text: メニューに表示される文字列か React Element
  • action: メニューが選択された際に実行されるアクション。実行された投稿の PostID が渡されます。(しかし、クリックしても何も実行されないようです)
  • filter: メニューを表示するかどうかを決定する関数。メニューが実行された投稿の PostID が渡されます。

registerPostDropdownSubMenuActionは、返り値として登録したアクションの ID と、サブメニューを登録するための関数を返します。ここで返される関数を使ってサブメニューを登録していきますが、この関数は 2 つの引数を取ります。

  • text: メニューに表示される文字列か React Element
  • action: メニューが選択された際に実行されるアクション。メニューが実行された投稿の PostID が渡されます。

(サブメニューを登録する関数の第 3 引数としてfilterとなる関数を渡しても、特に効果はないようです)

モーダルを開くアクションが登録されたドロップダウンサブメニューを追加する例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // 投稿に対するサブメニュー付きのドロップダウンメニューを追加する
        const {id, rootRegisterMenuItem} = registry.registerPostDropdownSubMenuAction(
            <i className='icon fa fa-plug' style={{fontSize: '15px'}}>{'Sample SubMenu'}</i>,
            (postId) => store.dispatch(openRootModal()),  // 実行されない
            (postId) => { return true; }
        );
        rootRegisterMenuItem(
            <i className='icon fa fa-plug' style={{fontSize: '15px'}}>{'SubMenu 1'}</i>,
            (postId) => store.dispatch(openRootModal()),
        );
        rootRegisterMenuItem(
            <i className='icon fa fa-plug' style={{fontSize: '15px'}}>{'SubMenu 2'}</i>,
            (postId) => store.dispatch(openRootModal())
        );
    }
}
...

registerPostDropdownMenuComponent

registerPostDropdownMenuComponentは、投稿に対するドロップダウンメニューに独自のコンポーネントを追加します。

登録した Component は以下の props を受け取れます。

  • postId: アクションが実行された投稿の PostID
  • theme: Mattermost 画面の配色テーマ constants.jsx
  • dispatch: action を dispatch するための関数

モーダルを開くアクションが登録されたドロップダウンメニューを追加する例を以下に示します。

index.js
...
import CustomPostDropdown from './components/custom_post_dropdown';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // 投稿に対するドロップダウンメニューにコンポーネントを登録する
        registry.registerPostDropdownMenuComponent(CustomPostDropdown);
		...
	}
}
components/custom_post_dropdown.jsx
import React from 'react';
import PropTypes from 'prop-types';

import {openRootModal} from 'actions';

const CustomPostDropdownComponent = ({postId, theme, dispatch}) => {
    return (
        <div
            style={{backgroundColor: '#ffcccc'}}
            onClick={() => dispatch(openRootModal())}
        >
            {postId}
        </div>
    )
}

CustomPostDropdownComponent.propTypes = {
    postId: PropTypes.string.isRequired,
    theme: PropTypes.object.isRequired,
    dispatch: PropTypes.func.isRequired,
};

export default CustomPostDropdownComponent;

registerFileUploadMethod

registerFileUploadMethodは、ファイルアップロードメニューに独自のメニューを追加します。

registerFileUploadMethodは 3 つの引数を取ります。(公式ドキュメントは引数の順序が違っているので注意)

  • icon: JSX 形式のメニューに表示されるアイコン
  • action: メニューが選択された際に実行されるアクション。ファイルをアップロードするための関数が渡されます。
  • text: メニューに表示されるテキスト

メニューが選択されると、テキストファイルが 2 つアップロードされる例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // ファイルアップロードメニューを追加する
        registry.registerFileUploadMethod(
            <i className='icon fa fa-pencil-square-o' style={{fontSize: '15px'}}/>,
            (upload) => upload([
                new File(["test1"], "sample1.txt"),
                new File(["test2"], 'sample2.txt')
            ]),
            'Sample File Upload'
        );
    }
}
...

registerFilesWillUploadHook

registerFilesWillUploadHookは、ファイルアップロード時に実行される関数を登録できます。

registerFilesWillUploadHookは 2 つの引数を取る関数を引数に取ります。

  • files: アップロードしようとしているファイルの配列
  • upload: ファイルをアップロードするための関数
    • この関数を使わなくとも、アップロードしたいファイルを返却値として指定することでファイルをアップロードできます

返り値として以下のフィールドを持つオブジェクトを返却する必要があります。

  • message: 処理結果として画面に表示されるメッセージを指定します
  • files: 処理結果としてアップロードするファイルを配列として指定します。nullを指定するとアップロードを reject します。

2 つ以上のファイルを同時にアップロードしようとするとアップロードを reject する例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...

        // ファイルアップロード時の処理を登録する
        registry.registerFilesWillUploadHook((files, upload) => {
            let msg = '';
            if (files.length >= 2 ) {
                files = null;
                msg = 'Must upload one by one.';
            }
            return {
                message: msg,
                files: files,
            };
        });
    }
}
...

unregisterComponent

unregisterComponentは、プラグインとして登録した Component や Hook を登録から除外します。

registerLeftSidebarHeaderComponentによって登録したコンポーネントを登録から除外するメインメニューの例を以下に示します。

index.js
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // 左サイドバーの上部に表示されるComponentの登録
        const leftSidebarHeaderComponentId = registry.registerLeftSidebarHeaderComponent(LeftSidebarHeader);
        ...
        // プラグインによって登録されたコンポーネントを登録から除外する
        registry.registerMainMenuAction(
            'Unregister LeftSideberHeader',
            () => registry.unregisterComponent(leftSidebarHeaderComponentId),
            () => (<i className='icon fa fa-plug' style={{fontSize: '15px', position: 'relative', top: '-1px'}}/>)
        );
    }
}
...

unregisterPostTypeComponent

unregisterPostTypeComponentは、unregisterComponentと同様、独自のPostTypeに対して設定した Component を登録から除外します。

例は省略します。

registerReducer

registerReducerは、プラグイン内で使用する Reducer を登録します。

ここまでに紹介してきた例では、モーダルを開くアクションが実行された場合などにregisterReducerによって登録された reducer を利用しています。

index.js
...
import reducer from './reducer';
...
export default class Plugin {
    // eslint-disable-next-line no-unused-vars
    initialize(registry, store) {
		...
        // Reducerを登録する
        registry.registerReducer(reducer);
    }
}
reducer.js
import {combineReducers} from 'redux';

import {OPEN_ROOT_MODAL, CLOSE_ROOT_MODAL} from './action_types';

const rootModalVisible = (state = false, action) => {
    switch (action.type) {
    case OPEN_ROOT_MODAL:
        return true;
    case CLOSE_ROOT_MODAL:
        return false;
    default:
        return state;
    }
};

export default combineReducers({
    rootModalVisible,
});
action_types.js
import {id as pluginId} from './manifest';

export const OPEN_ROOT_MODAL = pluginId + '_open_root_modal';
export const CLOSE_ROOT_MODAL = pluginId + '_close_root_modal';

さいごに

本日は、Mattermost Plugin のWebappサイドの実装について紹介しました。
明日は、残りのWebappサイドの API を紹介していきます。

Discussion