ついに画像が投稿できるようになります。第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)でセットする。手動でセットする。なんでや。
次回予告
次回は動画を投稿します。