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