SlackをTweetDeckぽく使えるChrome拡張機能を作った話


SlackのチャンネルをTweetDeckのように横に並べて見れるようにするChrome拡張機能「SlackDeck」を作りました。この記事では、なぜ開発にいたったのかやどうやって開発したのかについて記します。

作ったもの

上のGIFのように、SlackのURL( https://app.slack.com/client/... )からカラムを追加することで、チャンネルを横に並べることができます。設定からワークスペースURL( https://[workspace_name].slack.com/ )を登録しておくことで、メッセージやスレッド内メッセージのURLからでもカラムの追加が可能です。

追加したカラムには次の操作が可能です。

  • 削除
  • 並べ替え
  • カラム幅の変更
  • 名前を付ける

また、💾ボタンをクリックすることで追加したカラムを保存し、次回読み込み時に復元することができます。

開発経緯

私は普段、研究やアルバイト等の関係でSlackのチャンネルを頻繁に行き来します。しかし、Slackは1画面で2カラムまでしか分割できません(記事作成時点)。そのため、行き来の度にチャンネルを開き直す必要があり、非常に面倒でした。そこで、TweeetDeckみたいに1画面でもっとたくさんのカラムを表示できないか?という考えに至りました。

同じような考えの人が既に作ってくれていたりしないかなぁ…ということで調べてみると、いくつか見つけることができました。

そこで実際にこれらを導入してみたのですが、自分の用途だと微妙に適さないところがあったり、欲しい機能が出てきたりしました。ならば自分で作ってしまおう、ということで開発に至りました。

どうやって作ったか?

提供方法

提供方法はChrome拡張機能の形を取ることにしました。

デスクトップアプリを作ったり、サードパーティのSlackクライアントを作ったりすることも考えましたが、前者はOSごとにアプリを提供する必要があったり、改めてサインインが必要だったりと使い勝手が悪く、後者は公式が今後サポートしなくなっていきそう1とのことだったのでやめました。

技術選定

下記の技術を用いて開発を行いました。

React+TypeScriptの組み合わせを採用した理由は、型や構造などがある程度カッチリ決まっていた方が機能追加もメンテナンスもしやすいと考えたからです(実は一度、素のJavaScriptで開発を進めていて、これらに限界を感じました…当時のリポジトリはコチラ)。

Dockerを採用した理由は、開発環境からローカル環境の依存を排除するためです(個人的にローカル環境にあれこれインストールするのが好きではない、というのもあります)。

環境構築のためにReact+TypeScriptでChrome拡張機能を作っているリポジトリを漁っていたところ、そのためのテンプレートを作ってくださっている方を見つけたので、今回はそちらを利用しました。

仕組み

下記の記事の仕組みをベースに作りました。

仕組みについてざっくり説明すると、SlackのDOMにiframeをappendすることでチャンネルを横に並べています。

DOMとコンポーネントの構造

SlackDeckのDOMとcomponentの構造は上の画像の通りです。

SlackDeckのDOMは主に次の5種類で構成されています。

名称 役割 htmlタグ
newBody 新しいbody <body = id="newBody">
deck カラム追加ボタン等を配置する要素 <div id="deck">
mainBody 本来のSlackのDOMを描画する要素 <body id="mainBody">
wrapper カラムを追加するための要素 <div id="wrapper">
column カラムを描写する要素 <div class="column" id="col-el-[カラムの連番]">

また、SlackDeckのDOMは主に次の3種類で構成されています。

名称 役割
Main カラム追加ボタン等を配置するコンポーネント
AddColumnModal 追加したいチャンネルのURLを入力するモーダルのコンポーネント
Column カラムを描写するコンポーネント。URLをpropsに取る

処理の流れ

以下のタイミングでのSlackDeckの処理の流れを示します。

ページが読み込まれた時

  1. SlackのDOMを読み込み、そのbodyのidを"mainBody"に変更する
  2. 要素newBody、deck、wrapperを生成する
  3. 要素newBodyに次の順で要素をappendする:deck→mainBody→wrapper
  4. 要素deckにMainコンポーネントをレンダリングする

カラムを追加する時

  1. 要素deckから追加ボタン(「+」ボタン)をクリックする
  2. AddColumnModalコンポーネントが開くので、追加したいチャンネルのURLを指定する
  3. 要素columnを新たに生成し、指定されたURLをpropsで渡したColumnコンポーネントをレンダリングする
  4. 生成した要素columnを要素wrapperにappendする

カラムを削除する時

  1. 要素columnから削除ボタン(「X」ボタン)をクリックする
  2. 削除ボタンの要素のIDには予めカラムの連番が割り当てられている(例: col-del-btn-1 )ので、それを用いて何番目のカラムを移動させたいのか判別する
  3. 判別後、移動させたいカラムと移動先のカラムを Element.remove() メソッドを用いて削除する
  4. 他のカラムの要素のIDに割り当てられた連番を振り直す

カラムを並び替えるとき

  1. 要素columnから並び替えボタン(「<」ボタン、「>」ボタン)をクリックする
  2. 並び替えボタンの要素のIDには予めカラムの連番が割り当てられている(例: col-mv-l-btn-1 )ので、それを用いて何番目のカラムを移動させたいのか判別する
  3. 判別後、移動させたいカラムと移動先のカラムを Node.insertBefore() メソッドを用いて入れ替える
  4. カラムの要素のIDに割り当てられた連番を振り直す

技術的に難しかった点

Columnコンポーネントの制御

「DOMとコンポーネントの構造」セクションの図を見ると分かるのですが、SlackDeckはページ全体がコンポーネントで制御しているわけではなく、DOMの一部をコンポーネントで制御しています。そのため、Columnコンポーネントには親コンポーネントが存在せず、次のコードのようにループ処理でColumnコンポーネントを制御することができません。

{columnList.map((item, index) => {
  <Column columnIndex={index} />
})}

この問題の解決策として、次のコードのように、カラムを追加するときにDOMツリーに要素を追加してそこにColumnコンポーネントをレンダリングする、という方法を取っています。

let col = document.createElement('div');
ReactDOM.render(<Column columnIndex={i} />, col);
wrapper.appendChild(col);

何番目のカラムを操作したか判別できない

前セクションの「Columnコンポーネントの制御」とも関連します。カラムの削除ボタンや並び替えボタンはColumnコンポーネント内で定義されています。コンポーネントは「再利用できる部品」であるため、自身が何番目の要素か?といった情報は持っていません。これを解決する方法として、親コンポーネントからpropsでその情報を渡すという方法がありますが、前セクションで述べた通りColumnコンポーネントには親コンポーネントが無いので使えません。そこで次のコードのように、Columnコンポーネントのレンダリング先となる要素をColumnコンポーネントのpropsとして渡す方法を取りました。

AddColumnModal.tsx
let col = document.createElement('div');
ReactDOM.render(<Column
  columnIndex={props.columnList.length}
  columnElement={col}
/>, col);
document.getElementById('wrapper').appendChild(col);

こうすることで、Columnコンポーネント内で自身のレンダリング先となる要素を操作できるようになり、自身が何番目のカラムなのかの情報を持てるようになります。例えばカラムを削除する場合、次のコードのようにして削除したいカラムを判別することができます。

Column.tsx
const exttractColumnIdxFromId = (colDelBtnId: string) => parseInt(colDelBtnId.split('-').slice(-1)[0]);

export const Column: React.FC<{columnIndex: number, columnElement: HTMLDivElement}> = (props) => {
  ~~ 省略 ~~
  // 削除したいカラムの連番を削除ボタンのID(`col-del-btn-X`)から取得
  let colElIdx = exttractColumnIdxFromId(props.columnElement.getElementsByTagName('div')[0].id);
  ~~ 省略 ~~
}

まとめ

この記事では、SlackのチャンネルをTweetDeckのように横に並べて見れるようにするChrome拡張機能「SlackDeck」について、その開発経緯や仕組みについて解説しました。なお、バグがあったり謎の実装をしていたりする個所があったりするかと思いますので、その場合はコメントやPull Request等をいただけると助かります

ここまでお読みいただきありがとうございました!