Help us understand the problem. What is going on with this article?

Dive to mikutter その2 -タイムミュート機能-

More than 1 year has passed since last update.

タイムミュート?

前回、ID検索機能の実装はこちら。

前回でmikutterにID検索機能を追加することが出来ました。今回は全く別の機能として「タイムミュート機能」を実装するまでの解説を行いたいと思います。

タイムミュートというのは勝手な造語で、要するに時間指定ミュートのことです。この機能のベース自体は実はID検索よりも前に実装を行ったのですが、初めはテキストボックスに時間(分単位)を入力するとそのユーザーのツイートのみがhoge分ミュートされる、という単純で使いづらいものでした。

その後、TAの方の助言をもとにUIを大幅に改良し、時間指定の幅も広がりました。最終的な見た目は以下のようなものです。

Screenshot from 2015-11-03 10:13:38.png

画像の通り、日付はカレンダーでクリックしたものが下段のテキストボックスにリアルタイムで反映されます。またダイアログを開いた段階で「いつから」欄に現在の日付と時刻が入力されており、当日中に収まるミュートならば「いつまで」欄の時刻を入力すれば良いだけという仕様により、負担を軽減しています。

これから、このようなタイムミュートの実装について機能実装とUI実装に分けて説明していきますが、後者については前回のID検索を多少拡張したのみです。というのも、驚いたことにGtk+にはデフォルトでカレンダーが付属していましたので……。

どこでミュートしてるの

まずはミュート出来ないことには話は始まりません。mikutterにはプロフィールタブにミュートボタンが設置されているため、このボタンを押した時の挙動を追えば良さそうです。プロフィールといえば……ということで前回見つけたprofile.rbを再び眺めてみます。

コード内で「ミュート」と検索すると、以下のようなメソッドが見つかります。

profile.rb
def mutebutton(user)
    changer = lambda{ |new, widget|
      if new === nil
        UserConfig[:muted_users] and UserConfig[:muted_users].include?(user.idname)
      elsif new
        add_muted_user(user)
      else
        remove_muted_user(user)
      end
    }
    Mtk::boolean(changer, _('ミュート'))
 end

mutebuttonといういかにもな名前のメソッドです。最下段のMtk::boolean(changer, _('ミュート'))という箇所がボタンの本体だと推測できます。ミュートボタンはチェックボックスですから、booleanやchangerといった単語が目に入るのも頷けます。

そしてさらに注目すべきが、add_muted_user(user)remove_muted_user(user)の二つのメソッドでしょう。おそらく引数のuserは前回もお世話になったUserオブジェクトの事で、これをaddとremoveにそれぞれ渡してやればミュートユーザーに追加/削除されるのではないか、という期待が膨らみます。

実際にadd_muted_userの中身を見てやると(removeはほぼ同様の実装なので省略)、

profile.rb
def add_muted_user(user)
    type_strict user => User
    atomic{
      muted = (UserConfig[:muted_users] ||= []).melt
      muted << user.idname
      UserConfig[:muted_users] = muted } end

やはりuserの正体はUserオブジェクトだったようです。そしてUserConfigというハッシュテーブル中のmuted_usersをキーとした配列に、新たにミュートするユーザーの情報を加えているように見えます。ひとまず、このadd_muted_userはUserオブジェクトを渡しさえすればブラックボックスとして扱っても良さそうだと判断出来ました。

どうやって入力された時刻を保存するの

ここまでで、あるユーザーをミュートすることは出来そうです。しかし、例えば「hoge分ミュートしてくれ」という入力があった場合にその入力された時間はどこで保存しておけば良いのでしょうか。また、指定時間が過ぎる前に一旦mikutterを終了してしまっても、その時間が保持されるためにはどうしたら良いのでしょう。

ここでつい先ほど登場したUserConfigを思い起こしてみます。mikutterはあるユーザーをミュートした後にアプリケーションを再起動してもその情報は保持してくれるため、UserConfigに登録されている情報は保存されると推測できます。従って、まずはこれの行方を追ってみることにしましょう。再びgrepです。

grep -r "UserConfig"

すると、大量の検索結果に混じってこんな一行が見つかります。

core/userconfig.rb:#= UserConfig 動的な設定

userconfig.rbという名前まんまなファイルがフォルダ直下にあるようですので、眺めてみます。長くないコードなのでひと通り目を通してみると……どうやらこのコードの中でハッシュテーブルの様々な対応が決められているわけではないようです。そもそも、改めてadd_muted_users内の

muted = (UserConfig[:muted_users] ||= []).melt

という箇所を解釈してみると、UserConfigというハッシュテーブルについて、muted_usersというキーに対するハッシュが存在しなければ新たに配列をハッシュとして代入しています。つまりこの部分を見た限りでは、このテーブルは自由にキーを設定し要素を追加しても良いのではないか、と推測できます。

ここでミュートユーザーと時刻を対応させるため、UserConfigにtimemuteというキーに対応する新たなハッシュテーブルを追加することを考えます。ユーザーの固有ID(@以下のものではなく、登録時に割り振られる数字のほう)をキー、時刻をハッシュとすることで指定時間を保存しようというわけです。

考えた実装の流れをまとめると以下のようになります。
- UIを作り、ユーザーに時刻を入力してもらう
- UserConfig[:timemute]={"123456789" => 2015-11-03 17:28:09 +0900}といったようなハッシュテーブルを用い(123456789は固有ID)、時刻を保存する
- ツイートをリアルタイムで弾いている箇所を見つけ出し、その部分に時刻の比較をさせる。
- 開始時刻を過ぎていればadd_muted_usersにUserオブジェクトを渡し、ミュートを開始する。逆に終了時刻を過ぎていればremove_muted_usersによりミュートを解除する。

さて、この流れによると次にすべきことは「リアルタイムでツイートを弾いている箇所を探す」ことです。

どうやってツイートを弾いてるの

指定時刻を保存できたとして、実際にタイムミュートを実装するには現在時刻と比較するタイミングが必要です。当然ですが、Twitterクライアントは順々にツイートを読み込んでタイムラインに流しており、ミュートされているユーザーのツイートはその時点で何らかの処理により弾かれているはずですね。タイムミュートにおける時間比較のタイミングとしては、この「弾く」処理を行っている場所が最も適切ではないでしょうか。

まず、profile.rb内で":muted_users"と検索してみます。これは、ツイートを弾いている箇所でUserConfig[:muted_users]という配列が再び登場するだろう、という推測によるものです。すると、以下のような怪しげなプラグインが登場します。

profile.rb
filter_show_filter do |messages|
    muted_users = UserConfig[:muted_users]
    if muted_users && !muted_users.empty?
      [messages.select{ |m| !muted_users.include?(m.idname) && (m.receive_user_screen_names & muted_users).empty? }]
    else
      [messages] end end

show_filterという名前に加えてしっかりとmuted_usersが現れています。さらにmessages.select等の文言を見る限り、この箇所がまさにツイートを弾いていると判断して間違いないでしょう。

ついにUI実装!

ここまでの調査で、タイムミュートの機能を実装する下準備は整いました。後はきっちりとUIを作り、各々の箇所に適切な処理を施してやれば良いだけです。

そこまで複雑なことをしているわけでは無いのですが、例外処理等も加えるとタイムミュートプラグイン本体のコード行数は案外膨らんでしまいました。UIの実装については先述の通り、前回のID検索に毛が生えたようなものです。従って、以下では重要な箇所のみピックアップして解説を行っていくことにします。

まず、プラグインの定義はこのようになっています。

profile.rb
on_add_timemute do |user, label_Test|
...
...
end

これは、userとlabel_Timeを引数に持つadd_timemuteというプラグインの宣言です。最初にくっついているonは何かというと、これは他のプラグインの書き方に倣ったとしか言いようがありません。mikutter内の様々なコードを眺めてみると、Plugin.call(:hoge, ...)という形で呼ばれるプラグインの名前には全てこのような接頭辞がくっついていますので……。

次はカレンダーの定義です。

profile.rb
calendar_from = Gtk::Calendar.new
calendar_from.display_options(Gtk::Calendar::SHOW_HEADING || Gtk::Calendar::SHOW_DAY_NAMES)

「いつから」と「いつまで」を指定するため、カレンダーを二つ生成しますが、生成方法はボタンやテキストボックスと全く同じで、newを呼んでやるだけです。とっても簡単。二行目はオプション指定で、ヘッダーに年月を表示するようにしています(Gtk+のリファレンス参照)。

次は以下の画像(再掲)のように、テキストボックスとラベルの位置を整えながら配置していきます。これは前回と同じくaddを用いればOKです。

Screenshot from 2015-11-03 10:13:38.png

ついにダイアログを開きます。例外処理を省略したものの少し長いコードとなっていますが、順を追って説明していきます。

profile.rb
      dialog.signal_connect("response") do |widget, response|
        case response
        when Gtk::Dialog::RESPONSE_ACCEPT
         ...
         ...
         time_from = Time.local(date_from[0], date_from[1], date_from[2], date_from[3], date_from[4], date_from[5])
         time_to = Time.local(date_to[0], date_to[1], date_to[2], date_to[3], date_to[4], date_to[5])
         ...
         ...
          if fromto_or_to == false #現在からの指定時刻ミュート

            UserConfig[:timemute][user[:id]] = Array.new(2)
            UserConfig[:timemute][user[:id]][0] = 0
            UserConfig[:timemute][user[:id]][1] = time_to  

            if UserConfig[:muted_users].include?(user.idname)
            else
              add_muted_user(user)
            end

          elsif fromto_or_to == true

            UserConfig[:timemute][user[:id]] = Array.new(2)
            UserConfig[:timemute][user[:id]][0] = time_from
            UserConfig[:timemute][user[:id]][1] = time_to  

            if UserConfig[:muted_users].include?(user.idname)
            elsif time_from < nowtime
              add_muted_user(user)
            else
            end

          end

          if UserConfig[:timemute][user[:id]]
            label_Test.text = UserConfig[:timemute][user[:id]][0].strftime('  %m/%d %H:%M から ')
            label_Test.text += UserConfig[:timemute][user[:id]][1].strftime('%m/%d %H:%M までミュート')
          else
            label_Test.text = "時間が指定されていません。"
          end

          Gtk.main_quit
        when Gtk::Dialog::RESPONSE_CANCEL
          Gtk.main_quit
        when Gtk::Dialog::RESPONSE_REJECT
          Gtk.main_quit
        when Gtk::Dialog::RESPONSE_CLOSE
          Gtk.main_quit
        end
      end

      calendar_from.signal_connect('day_selected') do
        date_from[0] = calendar_from.date[0]
        date_from[1] = calendar_from.date[1]
        date_from[2] = calendar_from.date[2]
        dateprompt[0].text = date_from[1].to_s
        dateprompt[1].text = date_from[2].to_s
      end
      ...
      ...
      calendar_to.signal_connect('day_selected') do
      ...
      ...
      end

      dialog.show_all
      Gtk.main
      dialog.destroy

まずダイアログの開き方が前回と違います。前回用いていたdialog.runはGtk+の簡易ダイアログのためのツールで、ボタンがOKとキャンセルしか無いようなダイアログは簡単に生成することが出来ます。しかし逆に言うとそれらのボタンが押された時点でしか処理を行えないため、今回のようなカレンダーへの入力をリアルタイムにテキストボックスへ反映する、といった処理には向きません。

そこで、今回はGtk.mainを用いたイベントループを作成します。最下段でdialog.show_allとしてダイアログを開いた後、Gtk.mainを呼んでいますね。そして中段では、responseの状態による場合分けの果てにGtk.main_quitが置かれています。Gtk.mainが呼ばれてからquitされるまで、プログラムはダイアログ内をループし続けているため、カレンダーへの入力をリアルタイムで読み取ることが出来るということです。

その処理自体はどこで行っているのかというと、

profile.rb
      calendar_from.signal_connect('day_selected') do
        date_from[0] = calendar_from.date[0]
        date_from[1] = calendar_from.date[1]
        date_from[2] = calendar_from.date[2]
        dateprompt[0].text = date_from[1].to_s
        dateprompt[1].text = date_from[2].to_s
      end

ここです。一行目の書き方はGtk+で用意されているもので、日付がクリックされた時にこの処理が呼ばれるようになっています。2行目から4行目にかけてはdate_fromという配列要素に年月日をそれぞれ代入しており、5,6行目ではテキストボックスに日付を反映させています。この配列は上段でこのような使われ方をしています。

profile.rb
time_from = Time.local(date_from[0], date_from[1], date_from[2], date_from[3], date_from[4], date_from[5])

入力された日付からTimeオブジェクトを生成しています。これはまさにUserConfig[:timemute]内の固有IDに対するハッシュとなるもので、ミュート/非ミュートを司る大切な要素です。

profile.rb
            UserConfig[:timemute][user[:id]][0] = time_from
            UserConfig[:timemute][user[:id]][1] = time_to  

OKボタンが押された時、実際このようにして固有IDに対するハッシュとして代入されています。

こうしてようやく、入力された時刻をユーザーと対応付けて保存することが出来ました。後は仕上げとして先ほど見つけたミュートフィルタ内で時間比較を行えば、タイムミュート機能の完成です。

最後の仕上げ、時間比較

ミュートフィルタ内での実装を以下に記します。ここまで来ればあとは簡単で、純粋に現在時刻との比較を行って時間を過ぎていればミュートに追加/削除を行うのみです。

profile.rb
filter_show_filter do |messages| #ミュートフィルタ
    nowtime = Time.now
    muted_users = UserConfig[:muted_users]
    timemuted_users = UserConfig[:timemute]

    messages.each do |m|

      if UserConfig[:timemute][m.user.id]
        mutedtime_from = UserConfig[:timemute][m.user.id][0]
        mutedtime_to = UserConfig[:timemute][m.user.id][1]

        if mutedtime_from < nowtime && !muted_users.include?(m.user.id)
          add_muted_user(m.user)
        end

        if mutedtime_to < nowtime
          UserConfig[:timemute].delete(m.user.id)
          remove_muted_user(m.user)
        end 
        end end

    if muted_users && !muted_users.empty?
      [messages.select{ |m| !muted_users.include?(m.idname) && (m.receive_user_screen_names & muted_users).empty? }]
    else
      [messages] end end

フィルタの引数であるmessages内の要素各々に対し、each文でその発言者(ユーザー)がタイムミュートリストに含まれているか確認しています。リストから削除する際はdeleteメソッドでハッシュテーブルから消せば後処理は完了です。結局、最下段の「弾く」実装には全く手を加える必要はありませんでした。

遂にタイムミュート機能が実装できました。アニメ実況にTLを埋められたくない時など、是非ご活用下さい。もしワードミュート等と組み合わせることが出来れば、さらに実用性は高まるでしょう。

二回に渡ってmikutterへの機能追加について解説を行ってきました。ちょうどmikutterに新機能を追加しようと思っていた方や、それに限らずともOSSを弄ってみたいと思っていた方の参考になれば幸いです。

2kyym
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away