]> git.cworth.org Git - sup/blobdiff - lib/sup/modes/thread-view-mode.rb
reply all keybindings
[sup] / lib / sup / modes / thread-view-mode.rb
index 1f0078365e5ffba122bd4c499aa93238b15a4612..27167cbdd9750158f0a76dda51b4c11740a99c9a 100644 (file)
@@ -1,3 +1,4 @@
+require 'open3'
 module Redwood
 
 class ThreadViewMode < LineCursorMode
@@ -23,30 +24,69 @@ Return value:
   None. The variable 'headers' should be modified in place.
 EOS
 
+  HookManager.register "bounce-command", <<EOS
+Determines the command used to bounce a message.
+Variables:
+      from: The From header of the message being bounced
+            (eg: likely _not_ your address).
+        to: The addresses you asked the message to be bounced to as an array.
+Return value:
+  A string representing the command to pipe the mail into.  This
+  should include the entire command except for the destination addresses,
+  which will be appended by sup.
+EOS
+
   register_keymap do |k|
     k.add :toggle_detailed_header, "Toggle detailed header", 'h'
     k.add :show_header, "Show full message header", 'H'
+    k.add :show_message, "Show full message (raw form)", 'V'
     k.add :activate_chunk, "Expand/collapse or activate item", :enter
     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
     k.add :edit_draft, "Edit draft", 'e'
+    k.add :send_draft, "Send draft", 'y'
     k.add :edit_labels, "Edit or add labels for a thread", 'l'
     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
     k.add :jump_to_next_open, "Jump to next open message", 'n'
     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
+    k.add :align_current_message, "Align current message in buffer", 'z'
     k.add :toggle_starred, "Star or unstar message", '*'
-    k.add :toggle_new, "Toggle new/read status of message", 'N'
+    k.add :toggle_new, "Toggle unread/read status of message", 'N'
 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
     k.add :reply, "Reply to a message", 'r'
-    k.add :forward, "Forward a message", 'f'
+    k.add :reply_all, "Reply to all participants of this message", 'G'
+    k.add :forward, "Forward a message or attachment", 'f'
+    k.add :bounce, "Bounce message to other recipient(s)", '!'
     k.add :alias, "Edit alias/nickname for a person", 'i'
     k.add :edit_as_new, "Edit message as new", 'D'
     k.add :save_to_disk, "Save message/attachment to disk", 's'
     k.add :search, "Search for messages from particular people", 'S'
     k.add :compose, "Compose message to person", 'm'
-    k.add :archive_and_kill, "Archive thread and kill buffer", 'a'
-    k.add :delete_and_kill, "Delete thread and kill buffer", 'd'
     k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
     k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
+    k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
+      kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
+      kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
+      kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
+      kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
+      kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+      kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+      kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
+      kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
+      kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
+      kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
+      kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
+      kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
+      kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
+      kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n'
+    end
   end
 
   ## there are a couple important instance variables we hold to format
@@ -56,11 +96,15 @@ EOS
   ## Message objects.  @chunk_lines is a map from row #s to Chunk
   ## objects. @person_lines is a map from row #s to Person objects.
 
-  def initialize thread, hidden_labels=[]
+  def initialize thread, hidden_labels=[], index_mode=nil
     super()
     @thread = thread
     @hidden_labels = hidden_labels
 
+    ## used for dispatch-and-next
+    @index_mode = index_mode
+    @dying = false
+
     @layout = SavingHash.new { MessageLayout.new }
     @chunk_layout = SavingHash.new { ChunkLayout.new }
     earliest, latest = nil, nil
@@ -100,27 +144,36 @@ EOS
 
   def show_header
     m = @message_lines[curpos] or return
-    BufferManager.spawn_unless_exists("Full header") do
+    BufferManager.spawn_unless_exists("Full header for #{m.id}") do
       TextMode.new m.raw_header
     end
   end
 
+  def show_message
+    m = @message_lines[curpos] or return
+    BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
+      TextMode.new m.raw_message
+    end
+  end
+
   def toggle_detailed_header
     m = @message_lines[curpos] or return
     @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
     update
   end
 
-  def reply
+  def reply type_arg=nil
     m = @message_lines[curpos] or return
-    mode = ReplyMode.new m
+    mode = ReplyMode.new m, type_arg
     BufferManager.spawn "Reply to #{m.subj}", mode
   end
 
+  def reply_all; reply :all; end
+
   def subscribe_to_list
     m = @message_lines[curpos] or return
     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Subscribe header for this message."
     end
@@ -129,15 +182,50 @@ EOS
   def unsubscribe_from_list
     m = @message_lines[curpos] or return
     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Unsubscribe header for this message."
     end
   end
 
   def forward
+    if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
+      ForwardMode.spawn_nicely :attachments => [chunk]
+    elsif(m = @message_lines[curpos])
+      ForwardMode.spawn_nicely :message => m
+    end
+  end
+
+  def bounce
     m = @message_lines[curpos] or return
-    ForwardMode.spawn_nicely m
+    to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
+
+    defcmd = AccountManager.default_account.sendmail.sub(/\s(\-(ti|it|t))\b/) do |match|
+      case "$1"
+        when '-t' then ''
+        else ' -i'
+      end
+    end
+
+    cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
+          when nil, /^$/ then defcmd
+          else hookcmd
+          end + ' ' + to.map { |t| t.email }.join(' ')
+
+    bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
+
+    if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
+      debug "bounce command: #{cmd}"
+      begin
+        IO.popen(cmd, 'w') do |sm|
+          sm.puts m.raw_message
+        end
+        raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
+      rescue SystemCallError, SendmailCommandFailed => e
+        warn "problem sending mail: #{e.message}"
+        BufferManager.flash "Problem sending mail: #{e.message}"
+      end
+    end
   end
 
   include CanAliasContacts
@@ -157,7 +245,7 @@ EOS
   def compose
     p = @person_lines[curpos]
     if p
-      ComposeMode.spawn_nicely :to => [p]
+      ComposeMode.spawn_nicely :to_default => p
     else
       ComposeMode.spawn_nicely
     end
@@ -168,7 +256,7 @@ EOS
     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels
 
     return unless new_labels
-    @thread.labels = (reserved_labels + new_labels).uniq
+    @thread.labels = Set.new(reserved_labels) + new_labels
     new_labels.each { |l| LabelManager << l }
     update
     UpdateManager.relay self, :labeled, @thread.first
@@ -202,12 +290,16 @@ EOS
   ## view.
   def activate_chunk
     chunk = @chunk_lines[curpos] or return
-    layout = 
-      if chunk.is_a?(Message)
-        @layout[chunk]
-      elsif chunk.expandable?
-        @chunk_layout[chunk]
-      end
+    if chunk.is_a? Chunk::Text
+      ## if the cursor is over a text region, expand/collapse the
+      ## entire message
+      chunk = @message_lines[curpos]
+    end
+    layout = if chunk.is_a?(Message)
+      @layout[chunk]
+    elsif chunk.expandable?
+      @chunk_layout[chunk]
+    end
     if layout
       layout.state = (layout.state != :closed ? :closed : :open)
       #cursor_down if layout.state == :closed # too annoying
@@ -215,11 +307,15 @@ EOS
     elsif chunk.viewable?
       view chunk
     end
+    if chunk.is_a?(Message)
+      jump_to_message chunk
+      jump_to_next_open if layout.state == :closed
+    end
   end
 
   def edit_as_new
     m = @message_lines[curpos] or return
-    mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc)
+    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)
     BufferManager.spawn "edit as new", mode
     mode.edit_message
   end
@@ -228,7 +324,8 @@ EOS
     chunk = @chunk_lines[curpos] or return
     case chunk
     when Chunk::Attachment
-      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", chunk.filename
+      default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
+      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
     else
       m = @message_lines[curpos]
@@ -252,27 +349,46 @@ EOS
     end
   end
 
-  def jump_to_first_open
+  def send_draft
+    m = @message_lines[curpos] or return
+    if m.is_draft?
+      mode = ResumeMode.new m
+      BufferManager.spawn "Send message", mode
+      BufferManager.kill_buffer self.buffer
+      mode.send_message
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  def jump_to_first_open loose_alignment=false
     m = @message_lines[0] or return
     if @layout[m].state != :closed
-      jump_to_message m
+      jump_to_message m, loose_alignment
     else
-      jump_to_next_open
+      jump_to_next_open loose_alignment
     end
   end
 
-  def jump_to_next_open
+  def jump_to_next_open loose_alignment=false
     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
-    m = @message_lines[curpos] or return
+    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+    return unless m
     while nextm = @layout[m].next
       break if @layout[nextm].state != :closed
       m = nextm
     end
-    jump_to_message nextm if nextm
+    jump_to_message nextm, loose_alignment if nextm
   end
 
-  def jump_to_prev_open
+  def align_current_message
     m = @message_lines[curpos] or return
+    jump_to_message m
+  end
+
+  def jump_to_prev_open loose_alignment=false
+    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
+    return unless m
     ## jump to the top of the current message if we're in the body;
     ## otherwise, to the previous message
     
@@ -282,22 +398,35 @@ EOS
         break if @layout[prevm].state != :closed
         m = prevm
       end
-      jump_to_message prevm if prevm
+      jump_to_message prevm, loose_alignment if prevm
     else
-      jump_to_message m
+      jump_to_message m, loose_alignment
     end
   end
 
-  def jump_to_message m
+  IDEAL_TOP_CONTEXT = 3 # try and give 3 rows of top context
+  IDEAL_LEFT_CONTEXT = 4 # try and give 4 columns of left context
+  def jump_to_message m, loose_alignment=false
     l = @layout[m]
     left = l.depth * INDENT_SPACES
     right = left + l.width
 
-    ## jump to the top line unless both top and bottom fit in the current view
-    jump_to_line l.top unless l.top >= topline && l.top <= botline && l.bot >= topline && l.bot <= botline
+    ## jump to the top line
+    if loose_alignment
+      jump_to_line [l.top - IDEAL_TOP_CONTEXT, 0].max # give 3 lines of top context
+    else
+      jump_to_line l.top
+    end
+
+    ## jump to the left column
+    ideal_left = left +
+      if loose_alignment
+        -IDEAL_LEFT_CONTEXT + (l.width - buffer.content_width + IDEAL_LEFT_CONTEXT + 1).clamp(0, IDEAL_LEFT_CONTEXT)
+      else
+        0
+      end
 
-    ## jump to the left columns unless both left and right fit in the current view
-    jump_to_col left unless left >= leftcol && left <= rightcol && right >= leftcol && right <= rightcol
+    jump_to_col [ideal_left, 0].max
 
     ## either way, move the cursor to the first line
     set_cursor_pos l.top
@@ -329,16 +458,100 @@ EOS
     @layout = @chunk_layout = @text = nil # for good luck
   end
 
-  def archive_and_kill
-    @thread.remove_label :inbox
-    UpdateManager.relay self, :archived, @thread.first
-    BufferManager.kill_buffer_safely buffer
+  def archive_and_kill; archive_and_then :kill end
+  def spam_and_kill; spam_and_then :kill end
+  def delete_and_kill; delete_and_then :kill end
+  def unread_and_kill; unread_and_then :kill end
+
+  def archive_and_next; archive_and_then :next end
+  def spam_and_next; spam_and_then :next end
+  def delete_and_next; delete_and_then :next end
+  def unread_and_next; unread_and_then :next end
+  def do_nothing_and_next; do_nothing_and_then :next end
+
+  def archive_and_prev; archive_and_then :prev end
+  def spam_and_prev; spam_and_then :prev end
+  def delete_and_prev; delete_and_then :prev end
+  def unread_and_prev; unread_and_then :prev end
+  def do_nothing_and_prev; do_nothing_and_then :prev end
+
+  def archive_and_then op
+    dispatch op do
+      @thread.remove_label :inbox
+      UpdateManager.relay self, :archived, @thread.first
+    end
+  end
+
+  def spam_and_then op
+    dispatch op do
+      @thread.apply_label :spam
+      UpdateManager.relay self, :spammed, @thread.first
+    end
+  end
+
+  def delete_and_then op
+    dispatch op do
+      @thread.apply_label :deleted
+      UpdateManager.relay self, :deleted, @thread.first
+    end
+  end
+
+  def unread_and_then op
+    dispatch op do
+      @thread.apply_label :unread
+      UpdateManager.relay self, :unread, @thread.first
+    end
+  end
+
+  def do_nothing_and_then op
+    dispatch op
+  end
+
+  def dispatch op
+    return if @dying
+    @dying = true
+
+    l = lambda do
+      yield if block_given?
+      BufferManager.kill_buffer_safely buffer
+    end
+
+    case op
+    when :next
+      @index_mode.launch_next_thread_after @thread, &l
+    when :prev
+      @index_mode.launch_prev_thread_before @thread, &l
+    when :kill
+      l.call
+    else
+      raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
+    end
   end
+  private :dispatch
+
+  def pipe_message
+    chunk = @chunk_lines[curpos]
+    chunk = nil unless chunk.is_a?(Chunk::Attachment)
+    message = @message_lines[curpos] unless chunk
+
+    return unless chunk || message
+
+    command = BufferManager.ask(:shell, "pipe command: ")
+    return if command.nil? || command.empty?
+
+    output = pipe_to_process(command) do |stream|
+      if chunk
+        stream.print chunk.raw_content
+      else
+        message.each_raw_message_line { |l| stream.print l }
+      end
+    end
 
-  def delete_and_kill
-    @thread.apply_label :deleted
-    UpdateManager.relay self, :deleted, @thread.first
-    BufferManager.kill_buffer_safely buffer
+    if output
+      BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
+    else
+      BufferManager.flash "'#{command}' done!"
+    end
   end
 
 private
@@ -391,7 +604,7 @@ private
       (0 ... text.length).each do |i|
         @chunk_lines[@text.length + i] = m
         @message_lines[@text.length + i] = m
-        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum
+        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
       end
 
       @text += text
@@ -412,7 +625,7 @@ private
           (0 ... text.length).each do |i|
             @chunk_lines[@text.length + i] = c
             @message_lines[@text.length + i] = m
-            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum - (depth * INDENT_SPACES)
+            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
             l.width = lw if lw > l.width
           end
           @text += text
@@ -427,29 +640,29 @@ private
 
     open_widget = [color, (state == :closed ? "+ " : "- ")]
     new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
-    starred_widget = 
-      if m.has_label?(:starred)
-        [star_color, "* "]
+    starred_widget = if m.has_label?(:starred)
+        [star_color, "*"]
       else
-        [color, "  "]
+        [color, " "]
       end
+    attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
 
     case state
     when :open
       @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, starred_widget,
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
         [color, 
             "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
 
     when :closed
       @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, starred_widget,
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
         [color, 
         "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
 
     when :detailed
       @person_lines[start] = m.from
-      from_line = [[prefix_widget, open_widget, new_widget, starred_widget,
+      from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
           [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
 
       addressee_lines = []
@@ -486,7 +699,7 @@ private
 
   def format_person_list prefix, people
     ptext = people.map { |p| format_person p }
-    pad = " " * prefix.length
+    pad = " " * prefix.display_length
     [prefix + ptext.first + (ptext.length > 1 ? "," : "")] + 
       ptext[1 .. -1].map_with_index do |e, i|
         pad + e + (i == ptext.length - 1 ? "" : ",")
@@ -494,7 +707,7 @@ private
   end
 
   def format_person p
-    p.longname + (ContactManager.is_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
+    p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
   end
 
   ## todo: check arguments on this overly complex function
@@ -507,7 +720,7 @@ private
       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
     when Message
       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
-        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
+        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
 
     else
       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
@@ -532,7 +745,7 @@ private
     BufferManager.erase_flash
     BufferManager.completely_redraw_screen
     unless success
-      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s)
+      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
       BufferManager.flash "Couldn't execute view command, viewing as text."
     end
   end