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