]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-view-mode.rb
reply all keybindings
[sup] / lib / sup / modes / thread-view-mode.rb
1 require 'open3'
2 module Redwood
3
4 class ThreadViewMode < LineCursorMode
5   ## this holds all info we need to lay out a message
6   class MessageLayout
7     attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new
8   end
9
10   class ChunkLayout
11     attr_accessor :state
12   end
13
14   DATE_FORMAT = "%B %e %Y %l:%M%P"
15   INDENT_SPACES = 2 # how many spaces to indent child messages
16
17   HookManager.register "detailed-headers", <<EOS
18 Add or remove headers from the detailed header display of a message.
19 Variables:
20   message: The message whose headers are to be formatted.
21   headers: A hash of header (name, value) pairs, initialized to the default
22            headers.
23 Return value:
24   None. The variable 'headers' should be modified in place.
25 EOS
26
27   HookManager.register "bounce-command", <<EOS
28 Determines the command used to bounce a message.
29 Variables:
30       from: The From header of the message being bounced
31             (eg: likely _not_ your address).
32         to: The addresses you asked the message to be bounced to as an array.
33 Return value:
34   A string representing the command to pipe the mail into.  This
35   should include the entire command except for the destination addresses,
36   which will be appended by sup.
37 EOS
38
39   register_keymap do |k|
40     k.add :toggle_detailed_header, "Toggle detailed header", 'h'
41     k.add :show_header, "Show full message header", 'H'
42     k.add :show_message, "Show full message (raw form)", 'V'
43     k.add :activate_chunk, "Expand/collapse or activate item", :enter
44     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
45     k.add :edit_draft, "Edit draft", 'e'
46     k.add :send_draft, "Send draft", 'y'
47     k.add :edit_labels, "Edit or add labels for a thread", 'l'
48     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
49     k.add :jump_to_next_open, "Jump to next open message", 'n'
50     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
51     k.add :align_current_message, "Align current message in buffer", 'z'
52     k.add :toggle_starred, "Star or unstar message", '*'
53     k.add :toggle_new, "Toggle unread/read status of message", 'N'
54 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
55     k.add :reply, "Reply to a message", 'r'
56     k.add :reply_all, "Reply to all participants of this message", 'G'
57     k.add :forward, "Forward a message or attachment", 'f'
58     k.add :bounce, "Bounce message to other recipient(s)", '!'
59     k.add :alias, "Edit alias/nickname for a person", 'i'
60     k.add :edit_as_new, "Edit message as new", 'D'
61     k.add :save_to_disk, "Save message/attachment to disk", 's'
62     k.add :search, "Search for messages from particular people", 'S'
63     k.add :compose, "Compose message to person", 'm'
64     k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
65     k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
66     k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
67
68     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
69       kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
70       kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
71       kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
72       kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
73     end
74
75     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
76       kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
77       kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
78       kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
79       kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
80       kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
81     end
82
83     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
84       kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
85       kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
86       kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
87       kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
88       kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n'
89     end
90   end
91
92   ## there are a couple important instance variables we hold to format
93   ## the thread and to provide line-based functionality. @layout is a
94   ## map from Messages to MessageLayouts, and @chunk_layout from
95   ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
96   ## Message objects.  @chunk_lines is a map from row #s to Chunk
97   ## objects. @person_lines is a map from row #s to Person objects.
98
99   def initialize thread, hidden_labels=[], index_mode=nil
100     super()
101     @thread = thread
102     @hidden_labels = hidden_labels
103
104     ## used for dispatch-and-next
105     @index_mode = index_mode
106     @dying = false
107
108     @layout = SavingHash.new { MessageLayout.new }
109     @chunk_layout = SavingHash.new { ChunkLayout.new }
110     earliest, latest = nil, nil
111     latest_date = nil
112     altcolor = false
113
114     @thread.each do |m, d, p|
115       next unless m
116       earliest ||= m
117       @layout[m].state = initial_state_for m
118       @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
119       @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
120       @layout[m].orig_new = m.has_label? :read
121       altcolor = !altcolor
122       if latest_date.nil? || m.date > latest_date
123         latest_date = m.date
124         latest = m
125       end
126     end
127
128     @layout[latest].state = :open if @layout[latest].state == :closed
129     @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
130
131     @thread.remove_label :unread
132     regen_text
133   end
134
135   def draw_line ln, opts={}
136     if ln == curpos
137       super ln, :highlight => true
138     else
139       super
140     end
141   end
142   def lines; @text.length; end
143   def [] i; @text[i]; end
144
145   def show_header
146     m = @message_lines[curpos] or return
147     BufferManager.spawn_unless_exists("Full header for #{m.id}") do
148       TextMode.new m.raw_header
149     end
150   end
151
152   def show_message
153     m = @message_lines[curpos] or return
154     BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
155       TextMode.new m.raw_message
156     end
157   end
158
159   def toggle_detailed_header
160     m = @message_lines[curpos] or return
161     @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
162     update
163   end
164
165   def reply type_arg=nil
166     m = @message_lines[curpos] or return
167     mode = ReplyMode.new m, type_arg
168     BufferManager.spawn "Reply to #{m.subj}", mode
169   end
170
171   def reply_all; reply :all; end
172
173   def subscribe_to_list
174     m = @message_lines[curpos] or return
175     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
176       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
177     else
178       BufferManager.flash "Can't find List-Subscribe header for this message."
179     end
180   end
181
182   def unsubscribe_from_list
183     m = @message_lines[curpos] or return
184     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
185       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
186     else
187       BufferManager.flash "Can't find List-Unsubscribe header for this message."
188     end
189   end
190
191   def forward
192     if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
193       ForwardMode.spawn_nicely :attachments => [chunk]
194     elsif(m = @message_lines[curpos])
195       ForwardMode.spawn_nicely :message => m
196     end
197   end
198
199   def bounce
200     m = @message_lines[curpos] or return
201     to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
202
203     defcmd = AccountManager.default_account.sendmail.sub(/\s(\-(ti|it|t))\b/) do |match|
204       case "$1"
205         when '-t' then ''
206         else ' -i'
207       end
208     end
209
210     cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
211           when nil, /^$/ then defcmd
212           else hookcmd
213           end + ' ' + to.map { |t| t.email }.join(' ')
214
215     bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
216
217     if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
218       debug "bounce command: #{cmd}"
219       begin
220         IO.popen(cmd, 'w') do |sm|
221           sm.puts m.raw_message
222         end
223         raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
224       rescue SystemCallError, SendmailCommandFailed => e
225         warn "problem sending mail: #{e.message}"
226         BufferManager.flash "Problem sending mail: #{e.message}"
227       end
228     end
229   end
230
231   include CanAliasContacts
232   def alias
233     p = @person_lines[curpos] or return
234     alias_contact p
235     update
236   end
237
238   def search
239     p = @person_lines[curpos] or return
240     mode = PersonSearchResultsMode.new [p]
241     BufferManager.spawn "Search for #{p.name}", mode
242     mode.load_threads :num => mode.buffer.content_height
243   end    
244
245   def compose
246     p = @person_lines[curpos]
247     if p
248       ComposeMode.spawn_nicely :to_default => p
249     else
250       ComposeMode.spawn_nicely
251     end
252   end    
253
254   def edit_labels
255     reserved_labels = @thread.labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
256     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels
257
258     return unless new_labels
259     @thread.labels = Set.new(reserved_labels) + new_labels
260     new_labels.each { |l| LabelManager << l }
261     update
262     UpdateManager.relay self, :labeled, @thread.first
263   end
264
265   def toggle_starred
266     m = @message_lines[curpos] or return
267     toggle_label m, :starred
268   end
269
270   def toggle_new
271     m = @message_lines[curpos] or return
272     toggle_label m, :unread
273   end
274
275   def toggle_label m, label
276     if m.has_label? label
277       m.remove_label label
278     else
279       m.add_label label
280     end
281     ## TODO: don't recalculate EVERYTHING just to add a stupid little
282     ## star to the display
283     update
284     UpdateManager.relay self, :single_message_labeled, m
285   end
286
287   ## called when someone presses enter when the cursor is highlighting
288   ## a chunk. for expandable chunks (including messages) we toggle
289   ## open/closed state; for viewable chunks (like attachments) we
290   ## view.
291   def activate_chunk
292     chunk = @chunk_lines[curpos] or return
293     if chunk.is_a? Chunk::Text
294       ## if the cursor is over a text region, expand/collapse the
295       ## entire message
296       chunk = @message_lines[curpos]
297     end
298     layout = if chunk.is_a?(Message)
299       @layout[chunk]
300     elsif chunk.expandable?
301       @chunk_layout[chunk]
302     end
303     if layout
304       layout.state = (layout.state != :closed ? :closed : :open)
305       #cursor_down if layout.state == :closed # too annoying
306       update
307     elsif chunk.viewable?
308       view chunk
309     end
310     if chunk.is_a?(Message)
311       jump_to_message chunk
312       jump_to_next_open if layout.state == :closed
313     end
314   end
315
316   def edit_as_new
317     m = @message_lines[curpos] or return
318     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)
319     BufferManager.spawn "edit as new", mode
320     mode.edit_message
321   end
322
323   def save_to_disk
324     chunk = @chunk_lines[curpos] or return
325     case chunk
326     when Chunk::Attachment
327       default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
328       fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
329       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
330     else
331       m = @message_lines[curpos]
332       fn = BufferManager.ask_for_filename :filename, "Save message to file: "
333       return unless fn
334       save_to_file(fn) do |f|
335         m.each_raw_message_line { |l| f.print l }
336       end
337     end
338   end
339
340   def edit_draft
341     m = @message_lines[curpos] or return
342     if m.is_draft?
343       mode = ResumeMode.new m
344       BufferManager.spawn "Edit message", mode
345       BufferManager.kill_buffer self.buffer
346       mode.edit_message
347     else
348       BufferManager.flash "Not a draft message!"
349     end
350   end
351
352   def send_draft
353     m = @message_lines[curpos] or return
354     if m.is_draft?
355       mode = ResumeMode.new m
356       BufferManager.spawn "Send message", mode
357       BufferManager.kill_buffer self.buffer
358       mode.send_message
359     else
360       BufferManager.flash "Not a draft message!"
361     end
362   end
363
364   def jump_to_first_open loose_alignment=false
365     m = @message_lines[0] or return
366     if @layout[m].state != :closed
367       jump_to_message m, loose_alignment
368     else
369       jump_to_next_open loose_alignment
370     end
371   end
372
373   def jump_to_next_open loose_alignment=false
374     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
375     m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
376     return unless m
377     while nextm = @layout[m].next
378       break if @layout[nextm].state != :closed
379       m = nextm
380     end
381     jump_to_message nextm, loose_alignment if nextm
382   end
383
384   def align_current_message
385     m = @message_lines[curpos] or return
386     jump_to_message m
387   end
388
389   def jump_to_prev_open loose_alignment=false
390     m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
391     return unless m
392     ## jump to the top of the current message if we're in the body;
393     ## otherwise, to the previous message
394     
395     top = @layout[m].top
396     if curpos == top
397       while(prevm = @layout[m].prev)
398         break if @layout[prevm].state != :closed
399         m = prevm
400       end
401       jump_to_message prevm, loose_alignment if prevm
402     else
403       jump_to_message m, loose_alignment
404     end
405   end
406
407   IDEAL_TOP_CONTEXT = 3 # try and give 3 rows of top context
408   IDEAL_LEFT_CONTEXT = 4 # try and give 4 columns of left context
409   def jump_to_message m, loose_alignment=false
410     l = @layout[m]
411     left = l.depth * INDENT_SPACES
412     right = left + l.width
413
414     ## jump to the top line
415     if loose_alignment
416       jump_to_line [l.top - IDEAL_TOP_CONTEXT, 0].max # give 3 lines of top context
417     else
418       jump_to_line l.top
419     end
420
421     ## jump to the left column
422     ideal_left = left +
423       if loose_alignment
424         -IDEAL_LEFT_CONTEXT + (l.width - buffer.content_width + IDEAL_LEFT_CONTEXT + 1).clamp(0, IDEAL_LEFT_CONTEXT)
425       else
426         0
427       end
428
429     jump_to_col [ideal_left, 0].max
430
431     ## either way, move the cursor to the first line
432     set_cursor_pos l.top
433   end
434
435   def expand_all_messages
436     @global_message_state ||= :closed
437     @global_message_state = (@global_message_state == :closed ? :open : :closed)
438     @layout.each { |m, l| l.state = @global_message_state }
439     update
440   end
441
442   def collapse_non_new_messages
443     @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
444     update
445   end
446
447   def expand_all_quotes
448     if(m = @message_lines[curpos])
449       quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
450       numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
451       newstate = numopen > quotes.length / 2 ? :closed : :open
452       quotes.each { |c| @chunk_layout[c].state = newstate }
453       update
454     end
455   end
456
457   def cleanup
458     @layout = @chunk_layout = @text = nil # for good luck
459   end
460
461   def archive_and_kill; archive_and_then :kill end
462   def spam_and_kill; spam_and_then :kill end
463   def delete_and_kill; delete_and_then :kill end
464   def unread_and_kill; unread_and_then :kill end
465
466   def archive_and_next; archive_and_then :next end
467   def spam_and_next; spam_and_then :next end
468   def delete_and_next; delete_and_then :next end
469   def unread_and_next; unread_and_then :next end
470   def do_nothing_and_next; do_nothing_and_then :next end
471
472   def archive_and_prev; archive_and_then :prev end
473   def spam_and_prev; spam_and_then :prev end
474   def delete_and_prev; delete_and_then :prev end
475   def unread_and_prev; unread_and_then :prev end
476   def do_nothing_and_prev; do_nothing_and_then :prev end
477
478   def archive_and_then op
479     dispatch op do
480       @thread.remove_label :inbox
481       UpdateManager.relay self, :archived, @thread.first
482     end
483   end
484
485   def spam_and_then op
486     dispatch op do
487       @thread.apply_label :spam
488       UpdateManager.relay self, :spammed, @thread.first
489     end
490   end
491
492   def delete_and_then op
493     dispatch op do
494       @thread.apply_label :deleted
495       UpdateManager.relay self, :deleted, @thread.first
496     end
497   end
498
499   def unread_and_then op
500     dispatch op do
501       @thread.apply_label :unread
502       UpdateManager.relay self, :unread, @thread.first
503     end
504   end
505
506   def do_nothing_and_then op
507     dispatch op
508   end
509
510   def dispatch op
511     return if @dying
512     @dying = true
513
514     l = lambda do
515       yield if block_given?
516       BufferManager.kill_buffer_safely buffer
517     end
518
519     case op
520     when :next
521       @index_mode.launch_next_thread_after @thread, &l
522     when :prev
523       @index_mode.launch_prev_thread_before @thread, &l
524     when :kill
525       l.call
526     else
527       raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
528     end
529   end
530   private :dispatch
531
532   def pipe_message
533     chunk = @chunk_lines[curpos]
534     chunk = nil unless chunk.is_a?(Chunk::Attachment)
535     message = @message_lines[curpos] unless chunk
536
537     return unless chunk || message
538
539     command = BufferManager.ask(:shell, "pipe command: ")
540     return if command.nil? || command.empty?
541
542     output = pipe_to_process(command) do |stream|
543       if chunk
544         stream.print chunk.raw_content
545       else
546         message.each_raw_message_line { |l| stream.print l }
547       end
548     end
549
550     if output
551       BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
552     else
553       BufferManager.flash "'#{command}' done!"
554     end
555   end
556
557 private
558
559   def initial_state_for m
560     if m.has_label?(:starred) || m.has_label?(:unread)
561       :open
562     else
563       :closed
564     end
565   end
566
567   def update
568     regen_text
569     buffer.mark_dirty if buffer
570   end
571
572   ## here we generate the actual content lines. we accumulate
573   ## everything into @text, and we set @chunk_lines and
574   ## @message_lines, and we update @layout.
575   def regen_text
576     @text = []
577     @chunk_lines = []
578     @message_lines = []
579     @person_lines = []
580
581     prevm = nil
582     @thread.each do |m, depth, parent|
583       unless m.is_a? Message # handle nil and :fake_root
584         @text += chunk_to_lines m, nil, @text.length, depth, parent
585         next
586       end
587       l = @layout[m]
588
589       ## is this still necessary?
590       next unless @layout[m].state # skip discarded drafts
591
592       ## build the patina
593       text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
594       
595       l.top = @text.length
596       l.bot = @text.length + text.length # updated below
597       l.prev = prevm
598       l.next = nil
599       l.depth = depth
600       # l.state we preserve
601       l.width = 0 # updated below
602       @layout[l.prev].next = m if l.prev
603
604       (0 ... text.length).each do |i|
605         @chunk_lines[@text.length + i] = m
606         @message_lines[@text.length + i] = m
607         lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
608       end
609
610       @text += text
611       prevm = m 
612       if l.state != :closed
613         m.chunks.each do |c|
614           cl = @chunk_layout[c]
615
616           ## set the default state for chunks
617           cl.state ||=
618             if c.expandable? && c.respond_to?(:initial_state)
619               c.initial_state
620             else
621               :closed
622             end
623
624           text = chunk_to_lines c, cl.state, @text.length, depth
625           (0 ... text.length).each do |i|
626             @chunk_lines[@text.length + i] = c
627             @message_lines[@text.length + i] = m
628             lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
629             l.width = lw if lw > l.width
630           end
631           @text += text
632         end
633         @layout[m].bot = @text.length
634       end
635     end
636   end
637
638   def message_patina_lines m, state, start, parent, prefix, color, star_color
639     prefix_widget = [color, prefix]
640
641     open_widget = [color, (state == :closed ? "+ " : "- ")]
642     new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
643     starred_widget = if m.has_label?(:starred)
644         [star_color, "*"]
645       else
646         [color, " "]
647       end
648     attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
649
650     case state
651     when :open
652       @person_lines[start] = m.from
653       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
654         [color, 
655             "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
656
657     when :closed
658       @person_lines[start] = m.from
659       [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
660         [color, 
661         "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
662
663     when :detailed
664       @person_lines[start] = m.from
665       from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
666           [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
667
668       addressee_lines = []
669       unless m.to.empty?
670         m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
671         addressee_lines += format_person_list "   To: ", m.to
672       end
673       unless m.cc.empty?
674         m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
675         addressee_lines += format_person_list "   Cc: ", m.cc
676       end
677       unless m.bcc.empty?
678         m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
679         addressee_lines += format_person_list "   Bcc: ", m.bcc
680       end
681
682       headers = OrderedHash.new
683       headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
684       headers["Subject"] = m.subj
685
686       show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
687       unless show_labels.empty?
688         headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
689       end
690       if parent
691         headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
692       end
693
694       HookManager.run "detailed-headers", :message => m, :headers => headers
695       
696       from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
697     end
698   end
699
700   def format_person_list prefix, people
701     ptext = people.map { |p| format_person p }
702     pad = " " * prefix.display_length
703     [prefix + ptext.first + (ptext.length > 1 ? "," : "")] + 
704       ptext[1 .. -1].map_with_index do |e, i|
705         pad + e + (i == ptext.length - 1 ? "" : ",")
706       end
707   end
708
709   def format_person p
710     p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
711   end
712
713   ## todo: check arguments on this overly complex function
714   def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
715     prefix = " " * INDENT_SPACES * depth
716     case chunk
717     when :fake_root
718       [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
719     when nil
720       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
721     when Message
722       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
723         (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
724
725     else
726       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
727       if chunk.inlineable?
728         chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
729       elsif chunk.expandable?
730         case state
731         when :closed
732           [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
733         when :open
734           [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + chunk.lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
735         end
736       else
737         [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
738       end
739     end
740   end
741
742   def view chunk
743     BufferManager.flash "viewing #{chunk.content_type} attachment..."
744     success = chunk.view!
745     BufferManager.erase_flash
746     BufferManager.completely_redraw_screen
747     unless success
748       BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
749       BufferManager.flash "Couldn't execute view command, viewing as text."
750     end
751   end
752 end
753
754 end