sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

lib/sup/modes/thread_view_mode.rb (33963B) - raw

      1 module Redwood
      2 
      3 class ThreadViewMode < LineCursorMode
      4   ## this holds all info we need to lay out a message
      5   class MessageLayout
      6     attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
      7   end
      8 
      9   class ChunkLayout
     10     attr_accessor :state
     11   end
     12 
     13   HookManager.register "detailed-headers", <<EOS
     14 Add or remove headers from the detailed header display of a message.
     15 Variables:
     16   message: The message whose headers are to be formatted.
     17   headers: A hash of header (name, value) pairs, initialized to the default
     18            headers.
     19 Return value:
     20   None. The variable 'headers' should be modified in place.
     21 EOS
     22 
     23   HookManager.register "bounce-command", <<EOS
     24 Determines the command used to bounce a message.
     25 Variables:
     26       from: The From header of the message being bounced
     27             (eg: likely _not_ your address).
     28         to: The addresses you asked the message to be bounced to as an array.
     29 Return value:
     30   A string representing the command to pipe the mail into.  This
     31   should include the entire command except for the destination addresses,
     32   which will be appended by sup.
     33 EOS
     34 
     35   HookManager.register "publish", <<EOS
     36 Executed when a message or a chunk is requested to be published.
     37 Variables:
     38      chunk: Redwood::Message or Redwood::Chunk::* to be published.
     39 Return value:
     40   None.
     41 EOS
     42 
     43   HookManager.register "goto", <<EOS
     44 Open the uri given as a parameter.
     45 Variables:
     46       uri: The uri
     47 Return value:
     48   None.
     49 EOS
     50 
     51   register_keymap do |k|
     52     k.add :toggle_detailed_header, "Toggle detailed header", 'h'
     53     k.add :show_header, "Show full message header", 'H'
     54     k.add :show_message, "Show full message (raw form)", 'V'
     55     k.add :reload, "Update message in thread", '@'
     56     k.add :activate_chunk, "Expand/collapse or activate item", :enter
     57     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
     58     k.add :edit_draft, "Edit draft", 'e'
     59     k.add :send_draft, "Send draft", 'y'
     60     k.add :edit_labels, "Edit or add labels for a thread", 'l'
     61     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
     62     k.add :jump_to_next_open, "Jump to next open message", 'n'
     63     k.add :jump_to_next_and_open, "Jump to next message and open", "\C-n"
     64     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
     65     k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
     66     k.add :align_current_message, "Align current message in buffer", 'z'
     67     k.add :toggle_starred, "Star or unstar message", '*'
     68     k.add :toggle_new, "Toggle unread/read status of message", 'N'
     69 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
     70     k.add :reply, "Reply to a message", 'r'
     71     k.add :reply_all, "Reply to all participants of this message", 'G'
     72     k.add :forward, "Forward a message or attachment", 'f'
     73     k.add :bounce, "Bounce message to other recipient(s)", '!'
     74     k.add :alias, "Edit alias/nickname for a person", 'i'
     75     k.add :edit_as_new, "Edit message as new", 'D'
     76     k.add :save_to_disk, "Save message/attachment to disk", 's'
     77     k.add :save_all_to_disk, "Save all attachments to disk", 'A'
     78     k.add :publish, "Publish message/attachment using publish-hook", 'P'
     79     k.add :search, "Search for messages from particular people", 'S'
     80     k.add :compose, "Compose message to person", 'm'
     81     k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
     82     k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
     83     k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
     84 
     85     k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
     86     k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
     87     k.add :kill_and_next, "Kill this thread, kill buffer, and view next", '&'
     88     k.add :toggle_wrap, "Toggle wrapping of text", 'w'
     89 
     90     k.add :goto_uri, "Goto uri under cursor", 'g'
     91     k.add :fetch_and_verify, "Fetch the PGP key on poolserver and re-verify message", "v"
     92 
     93     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
     94       kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
     95       kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
     96       kk.add :kill_and_kill, "Kill this thread and kill buffer", '&'
     97       kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
     98       kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
     99       kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
    100     end
    101 
    102     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
    103       kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
    104       kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
    105       kk.add :kill_and_next, "Kill this thread, kill buffer, and view next", '&'
    106       kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
    107       kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
    108       kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
    109     end
    110 
    111     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
    112       kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
    113       kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
    114       kk.add :kill_and_prev, "Kill this thread, kill buffer, and view previous", '&'
    115       kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
    116       kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
    117       kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
    118     end
    119   end
    120 
    121   ## there are a couple important instance variables we hold to format
    122   ## the thread and to provide line-based functionality. @layout is a
    123   ## map from Messages to MessageLayouts, and @chunk_layout from
    124   ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
    125   ## Message objects.  @chunk_lines is a map from row #s to Chunk
    126   ## objects. @person_lines is a map from row #s to Person objects.
    127 
    128   def initialize thread, hidden_labels=[], index_mode=nil
    129     @indent_spaces = $config[:indent_spaces]
    130     super :slip_rows => $config[:slip_rows]
    131     @thread = thread
    132     @hidden_labels = hidden_labels
    133 
    134     ## used for dispatch-and-next
    135     @index_mode = index_mode
    136     @dying = false
    137 
    138     @layout = SavingHash.new { MessageLayout.new }
    139     @chunk_layout = SavingHash.new { ChunkLayout.new }
    140     earliest, latest = nil, nil
    141     latest_date = nil
    142     altcolor = false
    143 
    144     @thread.each do |m, d, p|
    145       next unless m
    146       earliest ||= m
    147       @layout[m].state = initial_state_for m
    148       @layout[m].toggled_state = false
    149       @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
    150       @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
    151       @layout[m].orig_new = m.has_label? :read
    152       altcolor = !altcolor
    153       if latest_date.nil? || m.date > latest_date
    154         latest_date = m.date
    155         latest = m
    156       end
    157     end
    158 
    159     @wrap = true
    160 
    161     @layout[latest].state = :open if @layout[latest].state == :closed
    162     @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
    163   end
    164 
    165   def toggle_wrap
    166     @wrap = !@wrap
    167     regen_text
    168     buffer.mark_dirty if buffer
    169   end
    170 
    171   def draw_line ln, opts={}
    172     if ln == curpos
    173       super ln, :highlight => true
    174     else
    175       super
    176     end
    177   end
    178   def lines; @text.length; end
    179   def [] i; @text[i]; end
    180 
    181   ## a little hacky---since regen_text can depend on buffer features like the
    182   ## content_width, we don't call it in the constructor, and instead call it
    183   ## here, which is set before we're responsible for drawing ourself.
    184   def buffer= b
    185     super
    186     regen_text
    187   end
    188 
    189   def show_header
    190     m = @message_lines[curpos] or return
    191     BufferManager.spawn_unless_exists("Full header for #{m.id}") do
    192       TextMode.new m.raw_header.ascii
    193     end
    194   end
    195 
    196   def show_message
    197     m = @message_lines[curpos] or return
    198     BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
    199       TextMode.new m.raw_message.ascii
    200     end
    201   end
    202 
    203   def toggle_detailed_header
    204     m = @message_lines[curpos] or return
    205     @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
    206     update
    207   end
    208 
    209   def reload
    210     update
    211   end
    212 
    213   def reply type_arg=nil
    214     m = @message_lines[curpos] or return
    215     mode = ReplyMode.new m, type_arg
    216     BufferManager.spawn "Reply to #{m.subj}", mode
    217   end
    218 
    219   def reply_all; reply :all; end
    220 
    221   def subscribe_to_list
    222     m = @message_lines[curpos] or return
    223     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
    224       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
    225     else
    226       BufferManager.flash "Can't find List-Subscribe header for this message."
    227     end
    228   end
    229 
    230   def unsubscribe_from_list
    231     m = @message_lines[curpos] or return
    232     BufferManager.flash "Can't find List-Unsubscribe header for this message." unless m.list_unsubscribe
    233 
    234     if m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
    235       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
    236     elsif m.list_unsubscribe =~ /<(http.*)?>/
    237       unless HookManager.enabled? "goto"
    238         BufferManager.flash "You must add a goto.rb hook before you can goto an unsubscribe URI."
    239         return
    240       end
    241 
    242       begin
    243         u = URI.parse($1)
    244       rescue URI::InvalidURIError
    245         BufferManager.flash("Invalid unsubscribe link")
    246         return
    247       end
    248 
    249       HookManager.run "goto", :uri => Shellwords.escape(u.to_s)
    250     end
    251   end
    252 
    253   def forward
    254     if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
    255       ForwardMode.spawn_nicely :attachments => [chunk]
    256     elsif(m = @message_lines[curpos])
    257       ForwardMode.spawn_nicely :message => m
    258     end
    259   end
    260 
    261   def bounce
    262     m = @message_lines[curpos] or return
    263     to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
    264 
    265     defcmd = AccountManager.default_account.bounce_sendmail
    266 
    267     cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
    268           when nil, /^$/ then defcmd
    269           else hookcmd
    270           end + ' ' + to.map { |t| t.email }.join(' ')
    271 
    272     bt = to.size > 1 ? "#{to.size} recipients" : to[0].to_s
    273 
    274     if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
    275       debug "bounce command: #{cmd}"
    276       begin
    277         IO.popen(cmd, 'w') do |sm|
    278           sm.puts m.raw_message
    279         end
    280         raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
    281         m.add_label :forwarded
    282         Index.save_message m
    283       rescue SystemCallError, SendmailCommandFailed => e
    284         warn "problem sending mail: #{e.message}"
    285         BufferManager.flash "Problem sending mail: #{e.message}"
    286       end
    287     end
    288   end
    289 
    290   include CanAliasContacts
    291   def alias
    292     p = @person_lines[curpos] or return
    293     alias_contact p
    294     update
    295   end
    296 
    297   def search
    298     p = @person_lines[curpos] or return
    299     mode = PersonSearchResultsMode.new [p]
    300     BufferManager.spawn "Search for #{p.name}", mode
    301   end
    302 
    303   def compose
    304     p = @person_lines[curpos]
    305     if p
    306       ComposeMode.spawn_nicely :to_default => p
    307     else
    308       ComposeMode.spawn_nicely
    309     end
    310   end
    311 
    312   def edit_labels
    313     old_labels = @thread.labels
    314     reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
    315     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
    316 
    317     return unless new_labels
    318     @thread.labels = Set.new(reserved_labels) + new_labels
    319     new_labels.each { |l| LabelManager << l }
    320     update
    321     UpdateManager.relay self, :labeled, @thread.first
    322     Index.save_thread @thread
    323     UndoManager.register "labeling thread" do
    324       @thread.labels = old_labels
    325       Index.save_thread @thread
    326       UpdateManager.relay self, :labeled, @thread.first
    327     end
    328   end
    329 
    330   def toggle_starred
    331     m = @message_lines[curpos] or return
    332     toggle_label m, :starred
    333   end
    334 
    335   def toggle_new
    336     m = @message_lines[curpos] or return
    337     toggle_label m, :unread
    338   end
    339 
    340   def toggle_label m, label
    341     if m.has_label? label
    342       m.remove_label label
    343     else
    344       m.add_label label
    345     end
    346     ## TODO: don't recalculate EVERYTHING just to add a stupid little
    347     ## star to the display
    348     update
    349     UpdateManager.relay self, :single_message_labeled, m
    350     Index.save_thread @thread
    351   end
    352 
    353   ## called when someone presses enter when the cursor is highlighting
    354   ## a chunk. for expandable chunks (including messages) we toggle
    355   ## open/closed state; for viewable chunks (like attachments) we
    356   ## view.
    357   def activate_chunk
    358     chunk = @chunk_lines[curpos] or return
    359     if chunk.is_a? Chunk::Text
    360       ## if the cursor is over a text region, expand/collapse the
    361       ## entire message
    362       chunk = @message_lines[curpos]
    363     end
    364     layout = if chunk.is_a?(Message)
    365       @layout[chunk]
    366     elsif chunk.expandable?
    367       @chunk_layout[chunk]
    368     end
    369     if layout
    370       layout.state = (layout.state != :closed ? :closed : :open)
    371       #cursor_down if layout.state == :closed # too annoying
    372       update
    373     elsif chunk.viewable?
    374       view chunk
    375     end
    376     if chunk.is_a?(Message) && $config[:jump_to_open_message]
    377       jump_to_message chunk
    378       jump_to_next_open if layout.state == :closed
    379     end
    380   end
    381 
    382   def edit_as_new
    383     m = @message_lines[curpos] or return
    384     mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
    385     BufferManager.spawn "edit as new", mode
    386     mode.default_edit_message
    387   end
    388 
    389   def save_to_disk
    390     chunk = @chunk_lines[curpos] or return
    391     case chunk
    392     when Chunk::Attachment
    393       default_dir = $config[:default_attachment_save_dir]
    394       default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
    395       default_fn = File.expand_path File.join(default_dir, chunk.filesafe_filename)
    396       fn = BufferManager.ask_for_filename :filename, "Save attachment to file or directory: ", default_fn, true
    397 
    398       # if user selects directory use file name from message
    399       if fn and File.directory? fn
    400         fn = File.join(fn, chunk.filename)
    401       end
    402 
    403       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
    404     else
    405       m = @message_lines[curpos]
    406       fn = BufferManager.ask_for_filename :filename, "Save message to file: "
    407       return unless fn
    408       save_to_file(fn) do |f|
    409         m.each_raw_message_line { |l| f.print l }
    410       end
    411     end
    412   end
    413 
    414   def save_all_to_disk
    415     m = @message_lines[curpos] or return
    416     default_dir = ($config[:default_attachment_save_dir] || ".")
    417     folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
    418     return unless folder
    419 
    420     num = 0
    421     num_errors = 0
    422     m.chunks.each do |chunk|
    423       next unless chunk.is_a?(Chunk::Attachment)
    424       fn = File.join(folder, chunk.filesafe_filename)
    425       num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
    426       num += 1
    427     end
    428 
    429     if num == 0
    430       BufferManager.flash "Didn't find any attachments!"
    431     else
    432       if num_errors == 0
    433         BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
    434       else
    435         BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
    436       end
    437     end
    438   end
    439 
    440   def publish
    441     chunk = @chunk_lines[curpos] or return
    442     if HookManager.enabled? "publish"
    443       HookManager.run "publish", :chunk => chunk
    444     else
    445       BufferManager.flash "Publishing hook not defined."
    446     end
    447   end
    448 
    449   def edit_draft
    450     m = @message_lines[curpos] or return
    451     if m.is_draft?
    452       mode = ResumeMode.new m
    453       BufferManager.spawn "Edit message", mode
    454       BufferManager.kill_buffer self.buffer
    455       mode.default_edit_message
    456     else
    457       BufferManager.flash "Not a draft message!"
    458     end
    459   end
    460 
    461   def send_draft
    462     m = @message_lines[curpos] or return
    463     if m.is_draft?
    464       mode = ResumeMode.new m
    465       BufferManager.spawn "Send message", mode
    466       BufferManager.kill_buffer self.buffer
    467       mode.send_message
    468     else
    469       BufferManager.flash "Not a draft message!"
    470     end
    471   end
    472 
    473   def jump_to_first_open
    474     m = @message_lines[0] or return
    475     if @layout[m].state != :closed
    476       jump_to_message m#, true
    477     else
    478       jump_to_next_open #true
    479     end
    480   end
    481 
    482   def jump_to_next_and_open
    483     return continue_search_in_buffer if in_search? # err.. don't know why im doing this
    484 
    485     m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
    486     return unless m
    487 
    488     nextm = @layout[m].next
    489     return unless nextm
    490 
    491     if @layout[m].toggled_state == true
    492       @layout[m].state = :closed
    493       @layout[m].toggled_state = false
    494       update
    495     end
    496 
    497     if @layout[nextm].state == :closed
    498       @layout[nextm].state = :open
    499       @layout[nextm].toggled_state = true
    500     end
    501 
    502     jump_to_message nextm if nextm
    503 
    504     update if @layout[nextm].toggled_state
    505   end
    506 
    507   def jump_to_next_open force_alignment=nil
    508     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
    509     m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
    510     return unless m
    511     while nextm = @layout[m].next
    512       break if @layout[nextm].state != :closed
    513       m = nextm
    514     end
    515     jump_to_message nextm, force_alignment if nextm
    516   end
    517 
    518   def align_current_message
    519     m = @message_lines[curpos] or return
    520     jump_to_message m, true
    521   end
    522 
    523   def jump_to_prev_and_open force_alignment=nil
    524     m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
    525     return unless m
    526 
    527     nextm = @layout[m].prev
    528     return unless nextm
    529 
    530     if @layout[m].toggled_state == true
    531       @layout[m].state = :closed
    532       @layout[m].toggled_state = false
    533       update
    534     end
    535 
    536     if @layout[nextm].state == :closed
    537       @layout[nextm].state = :open
    538       @layout[nextm].toggled_state = true
    539     end
    540 
    541     jump_to_message nextm if nextm
    542     update if @layout[nextm].toggled_state
    543   end
    544 
    545   def jump_to_prev_open
    546     m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
    547     return unless m
    548     ## jump to the top of the current message if we're in the body;
    549     ## otherwise, to the previous message
    550 
    551     top = @layout[m].top
    552     if curpos == top
    553       while(prevm = @layout[m].prev)
    554         break if @layout[prevm].state != :closed
    555         m = prevm
    556       end
    557       jump_to_message prevm if prevm
    558     else
    559       jump_to_message m
    560     end
    561   end
    562 
    563   def jump_to_message m, force_alignment=false
    564     l = @layout[m]
    565 
    566     ## boundaries of the message
    567     message_left = l.depth * @indent_spaces
    568     message_right = message_left + l.width
    569 
    570     ## calculate leftmost colum
    571     left = if force_alignment # force mode: align exactly
    572       message_left
    573     else # regular: minimize cursor movement
    574       ## leftmost and rightmost are boundaries of all valid left-column
    575       ## alignments.
    576       leftmost = [message_left, message_right - buffer.content_width + 1].min
    577       rightmost = message_left
    578       leftcol.clamp(leftmost, rightmost)
    579     end
    580 
    581     jump_to_line l.top    # move vertically
    582     jump_to_col left      # move horizontally
    583     set_cursor_pos l.top  # set cursor pos
    584   end
    585 
    586   def expand_all_messages
    587     @global_message_state ||= :closed
    588     @global_message_state = (@global_message_state == :closed ? :open : :closed)
    589     @layout.each { |m, l| l.state = @global_message_state }
    590     update
    591   end
    592 
    593   def collapse_non_new_messages
    594     @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
    595     update
    596   end
    597 
    598   def expand_all_quotes
    599     if(m = @message_lines[curpos])
    600       quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
    601       numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
    602       newstate = numopen > quotes.length / 2 ? :closed : :open
    603       quotes.each { |c| @chunk_layout[c].state = newstate }
    604       update
    605     end
    606   end
    607 
    608   def cleanup
    609     @layout = @chunk_layout = @text = nil # for good luck
    610   end
    611 
    612   def archive_and_kill; archive_and_then :kill end
    613   def spam_and_kill; spam_and_then :kill end
    614   def delete_and_kill; delete_and_then :kill end
    615   def kill_and_kill; kill_and_then :kill end
    616   def unread_and_kill; unread_and_then :kill end
    617   def do_nothing_and_kill; do_nothing_and_then :kill end
    618 
    619   def archive_and_next; archive_and_then :next end
    620   def spam_and_next; spam_and_then :next end
    621   def delete_and_next; delete_and_then :next end
    622   def kill_and_next; kill_and_then :next end
    623   def unread_and_next; unread_and_then :next end
    624   def do_nothing_and_next; do_nothing_and_then :next end
    625 
    626   def archive_and_prev; archive_and_then :prev end
    627   def spam_and_prev; spam_and_then :prev end
    628   def delete_and_prev; delete_and_then :prev end
    629   def kill_and_prev; kill_and_then :prev end
    630   def unread_and_prev; unread_and_then :prev end
    631   def do_nothing_and_prev; do_nothing_and_then :prev end
    632 
    633   def archive_and_then op
    634     dispatch op do
    635       @thread.remove_label :inbox
    636       UpdateManager.relay self, :archived, @thread.first
    637       Index.save_thread @thread
    638       UndoManager.register "archiving 1 thread" do
    639         @thread.apply_label :inbox
    640         Index.save_thread @thread
    641         UpdateManager.relay self, :unarchived, @thread.first
    642       end
    643     end
    644   end
    645 
    646   def spam_and_then op
    647     dispatch op do
    648       @thread.apply_label :spam
    649       UpdateManager.relay self, :spammed, @thread.first
    650       Index.save_thread @thread
    651       UndoManager.register "marking 1 thread as spam" do
    652         @thread.remove_label :spam
    653         Index.save_thread @thread
    654         UpdateManager.relay self, :unspammed, @thread.first
    655       end
    656     end
    657   end
    658 
    659   def delete_and_then op
    660     dispatch op do
    661       @thread.apply_label :deleted
    662       UpdateManager.relay self, :deleted, @thread.first
    663       Index.save_thread @thread
    664       UndoManager.register "deleting 1 thread" do
    665         @thread.remove_label :deleted
    666         Index.save_thread @thread
    667         UpdateManager.relay self, :undeleted, @thread.first
    668       end
    669     end
    670   end
    671 
    672   def kill_and_then op
    673     dispatch op do
    674       @thread.apply_label :killed
    675       UpdateManager.relay self, :killed, @thread.first
    676       Index.save_thread @thread
    677       UndoManager.register "killed 1 thread" do
    678         @thread.remove_label :killed
    679         Index.save_thread @thread
    680         UpdateManager.relay self, :unkilled, @thread.first
    681       end
    682     end
    683   end
    684 
    685   def unread_and_then op
    686     dispatch op do
    687       @thread.apply_label :unread
    688       UpdateManager.relay self, :unread, @thread.first
    689       Index.save_thread @thread
    690     end
    691   end
    692 
    693   def do_nothing_and_then op
    694     dispatch op
    695   end
    696 
    697   def dispatch op
    698     return if @dying
    699     @dying = true
    700 
    701     l = lambda do
    702       yield if block_given?
    703       BufferManager.kill_buffer_safely buffer
    704     end
    705 
    706     case op
    707     when :next
    708       @index_mode.launch_next_thread_after @thread, &l
    709     when :prev
    710       @index_mode.launch_prev_thread_before @thread, &l
    711     when :kill
    712       l.call
    713     else
    714       raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
    715     end
    716   end
    717   private :dispatch
    718 
    719   def pipe_message
    720     chunk = @chunk_lines[curpos]
    721     chunk = nil unless chunk.is_a?(Chunk::Attachment)
    722     message = @message_lines[curpos] unless chunk
    723 
    724     return unless chunk || message
    725 
    726     command = BufferManager.ask(:shell, "pipe command: ")
    727     return if command.nil? || command.empty?
    728 
    729     output, success = pipe_to_process(command) do |stream|
    730       if chunk
    731         stream.print chunk.raw_content
    732       else
    733         message.each_raw_message_line { |l| stream.print l }
    734       end
    735     end
    736 
    737     unless success
    738       BufferManager.flash "Invalid command: '#{command}' is not an executable"
    739       return
    740     end
    741 
    742     if output
    743       BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
    744     else
    745       BufferManager.flash "'#{command}' done!"
    746     end
    747   end
    748 
    749 
    750   def status
    751     user_labels = @thread.labels.to_a.map do |l|
    752       l.to_s if LabelManager.user_defined_labels.member?(l)
    753     end.compact.join(",")
    754     user_labels = (user_labels.empty? and "" or "<#{user_labels}>")
    755     [user_labels, super].join(" -- ")
    756   end
    757 
    758   def goto_uri
    759     unless (chunk = @chunk_lines[curpos])
    760       BufferManager.flash "No URI found."
    761       return
    762     end
    763     unless HookManager.enabled? "goto"
    764       BufferManager.flash "You must add a goto.rb hook before you can goto a URI."
    765       return
    766     end
    767 
    768     # @text is a list of lines with this format:
    769     # [
    770     #   [[:text_color, "Some text"]]
    771     #   [[:text_color, " continued here"]]
    772     # ]
    773 
    774     linetext = @text.slice(curpos, @text.length).flatten(1)
    775       .take_while{|d| [:text_color, :sig_color].include?(d[0]) and d[1].strip != ""} # Only take up to the first "" alone on its line
    776       .map{|d| d[1].strip}.join("").strip
    777 
    778     found = false
    779     URI.extract(linetext || "").each do |match|
    780       begin
    781         u = URI.parse(match)
    782         next unless u.absolute?
    783         next unless ["http", "https"].include?(u.scheme)
    784 
    785         reallink = Shellwords.escape(u.to_s)
    786         BufferManager.flash "Going to #{reallink} ..."
    787         HookManager.run "goto", :uri => reallink
    788         BufferManager.completely_redraw_screen
    789         found = true
    790 
    791       rescue URI::InvalidURIError => e
    792         debug "not a uri: #{e}"
    793         # Do nothing, this is an ok flow
    794       end
    795     end
    796     BufferManager.flash "No URI found." unless found
    797   end
    798 
    799   def fetch_and_verify
    800     message = @message_lines[curpos]
    801     crypto_chunk = message.chunks.select {|chunk| chunk.is_a?(Chunk::CryptoNotice)}.first
    802     return unless crypto_chunk
    803     return unless crypto_chunk.unknown_fingerprint
    804 
    805     BufferManager.flash "Retrieving key #{crypto_chunk.unknown_fingerprint} ..."
    806 
    807     error = CryptoManager.retrieve crypto_chunk.unknown_fingerprint
    808 
    809     if error
    810       BufferManager.flash "Couldn't retrieve key: #{error.to_s}"
    811     else
    812       BufferManager.flash "Key #{crypto_chunk.unknown_fingerprint} successfully retrieved !"
    813     end
    814 
    815     # Re-trigger gpg verification
    816     message.reload_from_source!
    817     update
    818   end
    819 
    820 private
    821 
    822   def initial_state_for m
    823     if m.has_label?(:starred) || m.has_label?(:unread)
    824       :open
    825     else
    826       :closed
    827     end
    828   end
    829 
    830   def update
    831     regen_text
    832     buffer.mark_dirty if buffer
    833   end
    834 
    835   ## here we generate the actual content lines. we accumulate
    836   ## everything into @text, and we set @chunk_lines and
    837   ## @message_lines, and we update @layout.
    838   def regen_text
    839     @text = []
    840     @chunk_lines = []
    841     @message_lines = []
    842     @person_lines = []
    843 
    844     prevm = nil
    845     @thread.each do |m, depth, parent|
    846       unless m.is_a? Message # handle nil and :fake_root
    847         @text += chunk_to_lines m, nil, @text.length, depth, parent
    848         next
    849       end
    850       l = @layout[m]
    851 
    852       ## is this still necessary?
    853       next unless @layout[m].state # skip discarded drafts
    854 
    855       ## build the patina
    856       text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
    857 
    858       l.top = @text.length
    859       l.bot = @text.length + text.length # updated below
    860       l.prev = prevm
    861       l.next = nil
    862       l.depth = depth
    863       # l.state we preserve
    864       l.width = 0 # updated below
    865       @layout[l.prev].next = m if l.prev
    866 
    867       (0 ... text.length).each do |i|
    868         @chunk_lines[@text.length + i] = m
    869         @message_lines[@text.length + i] = m
    870       end
    871 
    872       @text += text
    873       prevm = m
    874       if l.state != :closed
    875         m.chunks.each do |c|
    876           cl = @chunk_layout[c]
    877 
    878           ## set the default state for chunks
    879           cl.state ||=
    880             if c.expandable? && c.respond_to?(:initial_state)
    881               c.initial_state
    882             else
    883               :closed
    884             end
    885 
    886           text = chunk_to_lines c, cl.state, @text.length, depth
    887           (0 ... text.length).each do |i|
    888             @chunk_lines[@text.length + i] = c
    889             @message_lines[@text.length + i] = m
    890             lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * @indent_spaces)
    891             l.width = lw if lw > l.width
    892           end
    893           @text += text
    894         end
    895         @layout[m].bot = @text.length
    896       end
    897     end
    898   end
    899 
    900   def message_patina_lines m, state, start, parent, prefix, color, star_color
    901     prefix_widget = [color, prefix]
    902 
    903     open_widget = [color, (state == :closed ? "+ " : "- ")]
    904     new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
    905     starred_widget = if m.has_label?(:starred)
    906         [star_color, "*"]
    907       else
    908         [color, " "]
    909       end
    910     attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
    911 
    912     case state
    913     when :open
    914       @person_lines[start] = m.from
    915       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
    916         [color,
    917             "#{m.from ? m.from.mediumname.fix_encoding! : '?'} to #{m.recipients.map { |l| l.shortname.fix_encoding! }.join(', ')} #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!})"]]]
    918 
    919     when :closed
    920       @person_lines[start] = m.from
    921       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
    922         [color,
    923         "#{m.from ? m.from.mediumname.fix_encoding! : '?'}, #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!})  #{m.snippet ? m.snippet.fix_encoding! : ''}"]]]
    924 
    925     when :detailed
    926       @person_lines[start] = m.from
    927       from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
    928           [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
    929 
    930       addressee_lines = []
    931       unless m.to.empty?
    932         m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
    933         addressee_lines += format_person_list "   To: ", m.to
    934       end
    935       unless m.cc.empty?
    936         m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
    937         addressee_lines += format_person_list "   Cc: ", m.cc
    938       end
    939       unless m.bcc.empty?
    940         m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
    941         addressee_lines += format_person_list "   Bcc: ", m.bcc
    942       end
    943 
    944       headers = {
    945         "Date" => "#{m.date.to_message_nice_s} (#{m.date.to_nice_distance_s})",
    946         "Subject" => m.subj
    947       }
    948 
    949       show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
    950       unless show_labels.empty?
    951         headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
    952       end
    953       if parent
    954         headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.to_message_nice_s}"
    955       end
    956 
    957       HookManager.run "detailed-headers", :message => m, :headers => headers
    958 
    959       from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
    960     end
    961   end
    962 
    963   def format_person_list prefix, people
    964     ptext = people.map { |p| format_person p }
    965     pad = " " * prefix.display_length
    966     [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
    967       ptext[1 .. -1].map_with_index do |e, i|
    968         pad + e + (i == ptext.length - 1 ? "" : ",")
    969       end
    970   end
    971 
    972   def format_person p
    973     p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
    974   end
    975 
    976   def maybe_wrap_text lines
    977     if @wrap
    978       config_width = $config[:wrap_width]
    979       if config_width and config_width != 0
    980         width = [config_width, buffer.content_width].min
    981       else
    982         width = buffer.content_width
    983       end
    984       # lines can apparently be both String and Array, convert to Array for map.
    985       if lines.kind_of? String
    986         lines = lines.lines.to_a
    987       end
    988       lines = lines.map { |l| l.chomp.wrap width if l }.flatten
    989     end
    990     return lines
    991   end
    992 
    993   ## todo: check arguments on this overly complex function
    994   def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
    995     prefix = " " * @indent_spaces * depth
    996     case chunk
    997     when :fake_root
    998       [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
    999     when nil
   1000       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
   1001     when Message
   1002       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
   1003         (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
   1004 
   1005     else
   1006       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
   1007       if chunk.inlineable?
   1008         lines = maybe_wrap_text(chunk.lines)
   1009         lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
   1010       elsif chunk.expandable?
   1011         case state
   1012         when :closed
   1013           [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
   1014         when :open
   1015           lines = maybe_wrap_text(chunk.lines)
   1016           [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
   1017         end
   1018       else
   1019         [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
   1020       end
   1021     end
   1022   end
   1023 
   1024   def view chunk
   1025     BufferManager.flash "viewing #{chunk.content_type} attachment..."
   1026     success = chunk.view!
   1027     BufferManager.erase_flash
   1028     BufferManager.completely_redraw_screen
   1029     unless success
   1030       BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
   1031       BufferManager.flash "Couldn't execute view command, viewing as text."
   1032     end
   1033   end
   1034 end
   1035 
   1036 end