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