mikutterをいじりたおす 第5回

ついに画像が投稿できるようになります。第1回はこちら

画像投稿実装(後半)

前半はこちら

投稿ボックスの動きを追う

第1回で、core/mui/gtk_postbox.rbの中のpost_itが投稿ボタンをおした時の挙動を定義しているのだろう、と語った。実際に、以下のように実装されていて、どうやらそれで間違いない。

# 入力されている投稿する。投稿に成功したら、self.destroyを呼んで自分自身を削除する
def post_it
  if postable?
    return unless before_post
    text = widget_post.buffer.text
    text += UserConfig[:footer] if add_footer?
    @posting = service.post(:message => text){ |event, msg|
      case event
      when :start
        Delayer.new{ start_post }
      when :fail
        Delayer.new{ end_post }
      when :success
        Delayer.new{ destroy } end } end end

service.postは最終的に別スレッドでstatuses/updateを呼び出すだけなので、画像があったらここをif文でわけてあげればいいだろう、と思ったわけだ。つまり、returnの直後にif文をおいてそのまま実装した。するとなんということだろう、画像が追加されない。@picturesを出力してみると配列が空。一体どういうことなのか。

これはbefore_postにヒントがありそうだ。before_postを見てみよう。

# 新しいPostBoxを作り、そちらにフォーカスを回す
def before_post
  if(@options[:postboxstorage])
    return false if delegate
    if not @options[:delegated_by]
      postbox = Gtk::PostBox.new(@postable, @options)
      @options[:postboxstorage].
        pack_start(postbox).
        show_all.
        get_ancestor(Gtk::Window).
        set_focus(postbox.widget_post) end end
  if @options[:before_post_hook]
    @options[:before_post_hook].call(self) end
  Plugin.call(:before_postbox_post, widget_post.buffer.text)
  true end

なんということだ、新しいPostBoxを作るのだ。最初にreturn false if delegateとあって、falseが返るパターンはここしかない。すなわち、post_itの最初のunlessが動いて投稿できないのはこのパターンだけになる。ということはdelegateのなかで新しいPostBoxが定義されているのだろう。

delegateの定義を見ると、確かに新しいPostBoxを作っている。というわけで、この中でoptions[:pictures]に目的の配列を渡そう。以下の一行を適当に追加すればよい。

options[:pictures] = @pictures.collect{|p| {:path => p[:path]}}

これで新しいインスタンスにpicturesの配列が渡せる。次に、initializeの中で、:picturesが定義されていた場合にのみ@picturesにこれを代入するようにしよう。といっても以下のように書けばよい。

@pictures = options[:pictures] || []

さらに@picturesにもともと配列が入っていたらボタンが追加されるようにしよう。これは@pictureboxを作るときに行うのがよいと思うので、widget_picturebox関数の中で行う。

以下のように呼びだそう。add_pictureは第2引数があれば、そこのハッシュに代入してボタンを作り、@picturesにはそれを追加しない、という定義にすればよさそうだ。

@pictures.each{|p| add_picture(p[:path], p)}

add_pictureはこんな感じ。

def add_picture(filename, hash = nil)
  return if @pictures.count >= 4
  return if FileTest.directory?(filename)

  type = MIME::Types.type_for(filename)
  return if type.length ==0
  return unless type[0].content_type.index('image')
  return if File::stat(filename).size >= 3*1024*1024

  pixbuf = Gdk::Pixbuf.new(filename, 80, 80)
  preview = Gtk::Image.new
  preview.set_pixbuf(pixbuf)
  preview_button = Gtk::Button.new
  preview_button.add(preview)

  if hash
    hash[:instance] = preview_button
  else
    @pictures << {:path => filename, :instance => preview_button}
  end

  @picturebox_piclist.pack_start(preview_button, false)
  @picturebox_piclist.show_all

  preview_button.signal_connect('clicked') do |button|
    @pictures.delete_if{|h| h[:instance] == button}
    @picturebox_piclist.remove(button)
  end
end

これで新しいPostBoxに画像の配列が渡せて、さらにそれのボタンが追加されるようになった。

新しいPostBoxに画像の配列を渡してしまったらもとのPostBoxからは画像を削除しないといけない。そもそも投稿欄の文字も全部削除している。これはどこでやっているかというと、実は第1回で登場したpost_set_default_textなのだ。これのif文の1つめの条件分岐でこの処理をしている。というわけで、ここに元のPostBoxの画像を全部消す処理を追加しておく。

def post_set_default_text(post)
  if @options[:delegated_by]
    post.buffer.text = @options[:delegated_by].post.buffer.text
    @options[:delegated_by].post.buffer.text = ''
    @options[:delegated_by].clear_pictures
  elsif retweet?
    post.buffer.text = " RT @" + @postable.idname + ": " + @postable.to_show
    post.buffer.place_cursor(post.buffer.start_iter)
  elsif reply?
    post.buffer.text = reply_users + ' ' + post.buffer.text end
  post.accepts_tab = false end

clear_picturesは当然いま考えた関数なので、これも定義しよう。やらなければいけないことは、@picturesを空にすることと、ボタンを削除することだ。こんな感じになるだろうか。

def clear_pictures
  @pictures.delete_if do |p|
    @picturebox_piclist.remove(p[:instance])
    true
  end end

さて、ながながとデバッグをほったらかしてしまったので、post_itの中で@picturesが空でなかったら投稿しないでその@picturesを出力する処理をしよう。あくまでデバッグ用だ。unless @pictures.empty?; p @pictures; return endとでもしておこう。

投稿ボタンを押すと、投稿はされないが新しい投稿欄に画像が移動し、@picturesの配列が出力されたことと思う。ちゃんと@picturesにはファイル名が入っているだろう。

投稿する

さて、下準備は完了した。いよいよ投稿できる。今回はめんどくさいのでmikutterが築いてきた文化を破壊しつつquery!を直接叩く。脳がないのでThreadも作る。ごめんな

実験では投稿時もquery!を叩いていたのが、リプライ先が指定できないなどの問題があった。これを解消するためにlib/mikutwitter/api_shortcuts.rbをいじる。これは「in_reply_to_status」で全ファイル検索するとわかる。ここのupdateにservice.postを叩くとき通る。で、ここのオプションにmedia_idsを指定した時にちゃんと送信先にmedia_idsが送信されるようにしないといけない。

def update(message)
  text = message[:message]
  replyto = message[:replyto]
  receiver = message[:receiver]
  data = {:status => text }
  data[:in_reply_to_user_id] = User.generate(receiver)[:id].to_s if receiver
  data[:in_reply_to_status_id] = Message.generate(replyto)[:id].to_s if replyto
  data[:media_ids] = message[:media_ids] if message[:media_ids]
  (self/'statuses/update').message(data) end

こうすることで、オプションにmedia_idsを指定するとそれが送られるようになる。

次はpost_with_pictures関数の実装。実験では雑実装をしたが、Deferredとかいうものがあるのをやっとしったので、前回紹介したservice.rbに含まれているdefine_postalを参考に以下のように実装した。

def post_with_pictures(text, &callback)
  callback.call(:start, nil) if callback
  Thread.new(@pictures.collect{|p| p[:path]}){ |pictures|
    ids = []
    pictures.each do |p|
      res = media_upload(:media_data => Base64.encode64(File.binread(p)))
      if res.code.start_with?('2')
        ids << JSON.parse(res.body)['media_id_string']
      else
        raise 'media/upload failed'
      end
    end
    service.post({:message => text, :media_ids => ids.join(',')}){ |event, msg|
       case event
      when :success
        callback.call(:success, nil) if callback
      when :fail
        raise 'post with image(s) failed'
      end
    }
    callback.call(:success, nil) if callback
  }.trap{ |exception|
    callback.call(:fail, exception) if callback
  }
end

ThreadにはDeferred機能がついていて、trapだのnextだの使えるので、mikutterコンソールで遊んでみるとよい。遊べば最初のブロック→nextで指定したブロック→……と進むことがわかるはず。途中で例外が発生すれば(上ではraiseで発生させている)最初のtrapブロックにうつってそこまでのnextは実行されない。遊べることも能力のうち。

Thread.new{raise 'po'}.trap{|e| puts "1: #{e}"}.next{p 'pe'}.trap{|e| puts "2:#{e}"}.next{raise 'pa'}.next{p 'pi'}.trap{|e| puts"3:#{e}"}

などとmikutterコンソールで入力すると標準出力には何が出るか、考えながらやってみると理解に易いだろう。

さて、次はpost_with_picturesをpost_itから呼び出す。と言ってもこれは簡単。むしろ簡単にするためにpost_with_picturesをややこしくしたといっても過言ではない。以下のように、service.postと同じようにpost_with_picturesをすればよいのだ。

def post_it
  if postable?
    return unless before_post
    text = widget_post.buffer.text
    text += UserConfig[:footer] if add_footer?
    if @pictures.empty?
      @posting = service.post(:message => text){ |event, msg|
        case event
        when :start
          Delayer.new{ start_post }
        when :fail
          Delayer.new{ end_post }
        when :success
          Delayer.new{ destroy }
        end }
    else
      @posting = post_with_pictures(text){ |event, msg|
        case event
        when :start
          Delayer.new{ start_post }
        when :fail
          Delayer.new{ end_post }
        when :success
          Delayer.new{ destroy }
        end }
    end
  end
end

さあ、これで画像投稿実装はほぼ完成。実際にmikutterを起動して、その快適さを確かめて欲しい。

その他の細かい実装

そこそこ大事だけれど細かい実装をしておく。重要ではないのでサラッとコードを載せて説明するだけ。

投稿をキャンセルした時の挙動

投稿中にキャンセルすると文字列がもとの投稿欄に返ってくる。これはcancel_post関数のなかで処理している。もとのPostBoxのbufferに今の投稿欄のbufferの文字列を入れている場所があるので、その次に以下のようにでも書いておけばよい。

@pictures.each{|p| @options[:delegated_by].add_picture(p[:path])}

投稿中に画像の追加・削除を禁止する

ボタンや投稿欄のsensitive(編集・実行可能か)はrefresh_buttonsで定義されている。ここに画像の各ボタンの可否を書いておけばよい。実験での実装では以下の様な関数を用意して、これを引数なしでrefresh_buttonsから呼び出すことで実装した。

def append_picture_buttons(flag = !posting?)
  @picturebox_addbutton.sensitive = flag && @pictures.count < 4
  @pictures.each do |e|
    if e[:instance]
      e[:instance].sensitive = flag
    end
  end
end

なおrefresh_buttonsはif文で大きく2つのブロックにわかれているが、elseの方に書く。理由は忘れた。

画像追加時や削除時にappend_picture_buttonsを呼び出すことで、ボタンの可否をさらにリアルタイムに制御することもできる。また、ドラッグ・アンド・ドロップの実装の際にposting?がnilまたはfalseでないときにドラッグ・アンド・ドロップを拒否するようにすることも必要だ。

なおドラッグ・アンド・ドロップは、データを受け取る際に無視してもよいが、drag-motionシグナルのときにfalseを返すのがマナーらしい。

まあどうせ多少ガバガバ実装をしておいても、add_pictureは4個以上のときにはじくので問題ないが。

残り文字数を画像追加時に反映する

画像を追加すると25文字分奪われるので、この分残り文字数から引いておきたい。残り文字数をカウントする関数はremain_charcountなので、@picturesがempty?でないときに-25するようにしておく。

また残り文字数は@remain.set_text(remain_charcount.to_s)でセットする。手動でセットする。なんでや。

次回予告

次回は動画を投稿します。


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です