X-Git-Url: https://git.cworth.org/git?a=blobdiff_plain;f=lib%2Fsup%2Fmodes%2Fthread-view-mode.rb;h=a280440c73ba69b83f3da84ba42f58ec31033aea;hb=7640a4e99da251fb9f0284dd24c854f3795b1786;hp=e023c2660d4e47a7e654aeeb7f637613680df89b;hpb=87a8300bf03d82e4739c9acabf8862c4c0c1c822;p=sup diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb index e023c26..a280440 100644 --- a/lib/sup/modes/thread-view-mode.rb +++ b/lib/sup/modes/thread-view-mode.rb @@ -1,56 +1,81 @@ +require 'open3' module Redwood class ThreadViewMode < LineCursorMode ## this holds all info we need to lay out a message - class Layout - attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color + class MessageLayout + attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new + end + + class ChunkLayout + attr_accessor :state end DATE_FORMAT = "%B %e %Y %l:%M%P" INDENT_SPACES = 2 # how many spaces to indent child messages + HookManager.register "detailed-headers", < latest_date latest_date = m.date @@ -61,7 +86,8 @@ class ThreadViewMode < LineCursorMode @layout[latest].state = :open if @layout[latest].state == :closed @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1 - BufferManager.say("Loading message bodies...") { regen_text } + @thread.remove_label :unread + regen_text end def draw_line ln, opts={} @@ -93,80 +119,139 @@ class ThreadViewMode < LineCursorMode BufferManager.spawn "Reply to #{m.subj}", mode end - def forward + def subscribe_to_list + m = @message_lines[curpos] or return + if m.list_subscribe && m.list_subscribe =~ // + ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3 + else + BufferManager.flash "Can't find List-Subscribe header for this message." + end + end + + def unsubscribe_from_list m = @message_lines[curpos] or return - mode = ForwardMode.new m - BufferManager.spawn "Forward of #{m.subj}", mode - mode.edit + if m.list_unsubscribe && m.list_unsubscribe =~ // + ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($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 include CanAliasContacts def alias p = @person_lines[curpos] or return alias_contact p - regen_text + update end def search p = @person_lines[curpos] or return mode = PersonSearchResultsMode.new [p] - BufferManager.spawn "search for #{p.name}", mode + BufferManager.spawn "Search for #{p.name}", mode mode.load_threads :num => mode.buffer.content_height end + def compose + p = @person_lines[curpos] + if p + ComposeMode.spawn_nicely :to => [p] + else + ComposeMode.spawn_nicely + end + end + + def edit_labels + reserved_labels = @thread.labels.select { |l| LabelManager::RESERVED_LABELS.include? l } + new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels + + return unless new_labels + @thread.labels = (reserved_labels + new_labels).uniq + new_labels.each { |l| LabelManager << l } + update + UpdateManager.relay self, :labeled, @thread.first + end + def toggle_starred m = @message_lines[curpos] or return - if m.has_label? :starred - m.remove_label :starred + toggle_label m, :starred + end + + def toggle_new + m = @message_lines[curpos] or return + toggle_label m, :unread + end + + def toggle_label m, label + if m.has_label? label + m.remove_label label else - m.add_label :starred + m.add_label label end ## TODO: don't recalculate EVERYTHING just to add a stupid little ## star to the display update - UpdateManager.relay :starred, m + UpdateManager.relay self, :single_message_labeled, m end - def toggle_expanded + ## called when someone presses enter when the cursor is highlighting + ## a chunk. for expandable chunks (including messages) we toggle + ## open/closed state; for viewable chunks (like attachments) we + ## view. + def activate_chunk chunk = @chunk_lines[curpos] or return - case chunk - when Message, Message::Quote, Message::Signature - return if chunk.lines.length == 1 unless chunk.is_a? Message # too small to expand/close - l = @layout[chunk] - l.state = (l.state != :closed ? :closed : :open) - cursor_down if l.state == :closed - when Message::Attachment - view_attachment chunk + 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 + update + elsif chunk.viewable? + view chunk end - update end def edit_as_new m = @message_lines[curpos] or return - mode = ComposeMode.new(:body => m.basic_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) BufferManager.spawn "edit as new", mode - mode.edit + mode.edit_message end def save_to_disk chunk = @chunk_lines[curpos] or return case chunk - when Message::Attachment - fn = BufferManager.ask :filename, "Save attachment to file: ", chunk.filename - save_to_file(fn) { |f| f.print chunk } if fn + when Chunk::Attachment + fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", chunk.filename + save_to_file(fn) { |f| f.print chunk.raw_content } if fn else m = @message_lines[curpos] - fn = BufferManager.ask :filename, "Save message to file: " - save_to_file(fn) { |f| f.print m.raw_full_message } if fn + fn = BufferManager.ask_for_filename :filename, "Save message to file: " + return unless fn + save_to_file(fn) do |f| + m.each_raw_message_line { |l| f.print l } + end end end - def edit_message + def edit_draft m = @message_lines[curpos] or return if m.is_draft? mode = ResumeMode.new m BufferManager.spawn "Edit message", mode - mode.edit + BufferManager.kill_buffer self.buffer + mode.edit_message else BufferManager.flash "Not a draft message!" end @@ -182,6 +267,7 @@ class ThreadViewMode < LineCursorMode end def jump_to_next_open + return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations m = @message_lines[curpos] or return while nextm = @layout[m].next break if @layout[nextm].state != :closed @@ -225,39 +311,67 @@ class ThreadViewMode < LineCursorMode def expand_all_messages @global_message_state ||= :closed @global_message_state = (@global_message_state == :closed ? :open : :closed) - @layout.each { |m, l| l.state = @global_message_state if m.is_a? Message } + @layout.each { |m, l| l.state = @global_message_state } update end def collapse_non_new_messages - @layout.each { |m, l| l.state = m.has_label?(:unread) ? :open : :closed } + @layout.each { |m, l| l.state = l.orig_new ? :open : :closed } update end def expand_all_quotes if(m = @message_lines[curpos]) - quotes = m.chunks.select { |c| (c.is_a?(Message::Quote) || c.is_a?(Message::Signature)) && c.lines.length > 1 } - numopen = quotes.inject(0) { |s, c| s + (@layout[c].state && @layout[c].state == :open ? 1 : 0) } + quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 } + numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) } newstate = numopen > quotes.length / 2 ? :closed : :open - quotes.each { |c| @layout[c].state = newstate } + quotes.each { |c| @chunk_layout[c].state = newstate } update end end - ## kinda slow for large threads. TODO: fasterify def cleanup - BufferManager.say "Marking messages as read..." do - @thread.each do |m, d, p| - if m && m.has_label?(:unread) - m.remove_label :unread - UpdateManager.relay :read, m - end + @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 + end + + def delete_and_kill + @thread.apply_label :deleted + UpdateManager.relay self, :deleted, @thread.first + BufferManager.kill_buffer_safely buffer + end + + 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 - @layout = @text = nil + + if output + BufferManager.spawn "Output of '#{command}'", TextMode.new(output) + else + BufferManager.flash "'#{command}' done!" + end end -private +private def initial_state_for m if m.has_label?(:starred) || m.has_label?(:unread) @@ -287,27 +401,13 @@ private @text += chunk_to_lines m, nil, @text.length, depth, parent next end - - ## we're occasionally called on @threads that have had messages - ## added to them since initialization. luckily we regen_text on - ## the entire thread every time the user does anything besides - ## scrolling (basically), so we can just slap this on here. - ## - ## to pick nits, the niceness that i do in the constructor with - ## 'latest' etc. (for automatically opening just the latest - ## message if everything's been read) will not be valid, but - ## that's just a nicety and hopefully this won't happen too - ## often. - - unless @layout.member? m - l = @layout[m] = Layout.new - l.state = initial_state_for m - l.color = prevm && @layout[prevm].color == :message_patina_color ? :alternate_patina_color : :message_patina_color - end l = @layout[m] + ## is this still necessary? + next unless @layout[m].state # skip discarded drafts + ## build the patina - text = chunk_to_lines m, l.state, @text.length, depth, parent, @layout[m].color + text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color l.top = @text.length l.bot = @text.length + text.length # updated below @@ -326,10 +426,18 @@ private @text += text prevm = m - if @layout[m].state != :closed + if l.state != :closed m.chunks.each do |c| - cl = (@layout[c] ||= Layout.new) - cl.state ||= :closed + cl = @chunk_layout[c] + + ## set the default state for chunks + cl.state ||= + if c.expandable? && c.respond_to?(:initial_state) + c.initial_state + else + :closed + end + text = chunk_to_lines c, cl.state, @text.length, depth (0 ... text.length).each do |i| @chunk_lines[@text.length + i] = c @@ -344,18 +452,14 @@ private end end - def message_patina_lines m, state, start, parent, prefix, color + def message_patina_lines m, state, start, parent, prefix, color, star_color prefix_widget = [color, prefix] - widget = - case state - when :closed - [color, "+ "] - when :open, :detailed - [color, "- "] - end - imp_widget = + + open_widget = [color, (state == :closed ? "+ " : "- ")] + new_widget = [color, (m.has_label?(:unread) ? "N" : " ")] + starred_widget = if m.has_label?(:starred) - [:starred_patina_color, "* "] + [star_color, "* "] else [color, " "] end @@ -363,42 +467,50 @@ private case state when :open @person_lines[start] = m.from - [[prefix_widget, widget, imp_widget, + [[prefix_widget, open_widget, new_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, widget, imp_widget, + [[prefix_widget, open_widget, new_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 = [[prefix_widget, widget, imp_widget, [color, "From: #{m.from ? format_person(m.from) : '?'}"]]] + from_line = [[prefix_widget, open_widget, new_widget, starred_widget, + [color, "From: #{m.from ? format_person(m.from) : '?'}"]]] - rest = [] + addressee_lines = [] unless m.to.empty? - m.to.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p } - rest += format_person_list " To: ", m.to + m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } + addressee_lines += format_person_list " To: ", m.to end unless m.cc.empty? - m.cc.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p } - rest += format_person_list " Cc: ", m.cc + m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } + addressee_lines += format_person_list " Cc: ", m.cc end unless m.bcc.empty? - m.bcc.each_with_index { |p, i| @person_lines[start + rest.length + from.length + i] = p } - rest += format_person_list " Bcc: ", m.bcc + m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p } + addressee_lines += format_person_list " Bcc: ", m.bcc end - rest += [ - " Date: #{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})", - " Subject: #{m.subj}", - (parent ? " In reply to: #{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}" : nil), - m.labels.empty? ? nil : " Labels: #{m.labels.join(', ')}", - ].compact + headers = OrderedHash.new + headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})" + headers["Subject"] = m.subj + + show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS + unless show_labels.empty? + headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ') + end + if parent + headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}" + end + + HookManager.run "detailed-headers", :message => m, :headers => headers - from + rest.map { |l| [[color, prefix + " " + l]] } + from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] } end end @@ -412,10 +524,11 @@ 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 - def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil + ## todo: check arguments on this overly complex function + def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil prefix = " " * INDENT_SPACES * depth case chunk when :fake_root @@ -423,44 +536,36 @@ private when nil [[[:missing_message_color, "#{prefix}"]]] when Message - message_patina_lines(chunk, state, start, parent, prefix, color) + + 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'. <<<"]]] : []) - when Message::Attachment - [[[:mime_color, "#{prefix}+ MIME attachment #{chunk.content_type}#{chunk.desc ? ' (' + chunk.desc + ')': ''}"]]] - when Message::Text - t = chunk.lines - if t.last =~ /^\s*$/ && t.length > 1 - t.pop while t[-2] =~ /^\s*$/ # pop until only one file empty line - end - t.map { |line| [[:none, "#{prefix}#{line}"]] } - when Message::Quote - return [[[:quote_color, "#{prefix}#{chunk.lines.first}"]]] if chunk.lines.length == 1 - case state - when :closed - [[[:quote_patina_color, "#{prefix}+ (#{chunk.lines.length} quoted lines)"]]] - when :open - [[[:quote_patina_color, "#{prefix}- (#{chunk.lines.length} quoted lines)"]]] + chunk.lines.map { |line| [[:quote_color, "#{prefix}#{line}"]] } - end - when Message::Signature - return [[[:sig_patina_color, "#{prefix}#{chunk.lines.first}"]]] if chunk.lines.length == 1 - case state - when :closed - [[[:sig_patina_color, "#{prefix}+ (#{chunk.lines.length}-line signature)"]]] - when :open - [[[:sig_patina_color, "#{prefix}- (#{chunk.lines.length}-line signature)"]]] + chunk.lines.map { |line| [[:sig_color, "#{prefix}#{line}"]] } - end + else - raise "unknown chunk type #{chunk.class.name}" + raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging + if chunk.inlineable? + chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] } + elsif chunk.expandable? + case state + when :closed + [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]] + when :open + [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] } + end + else + [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]] + end end end - def view_attachment a - BufferManager.flash "viewing #{a.content_type} attachment..." - a.view! + def view chunk + BufferManager.flash "viewing #{chunk.content_type} attachment..." + success = chunk.view! BufferManager.erase_flash BufferManager.completely_redraw_screen + unless success + BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s) + BufferManager.flash "Couldn't execute view command, viewing as text." + end end - end end