]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-view-mode.rb
Merge branch 'master' into next
[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 :reply_all, "Reply to all participants of this message", 'G'
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 :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
68     k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
69
70     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
71       kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
72       kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
73       kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
74       kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
75     end
76
77     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
78       kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
79       kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
80       kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
81       kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
82       kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
83     end
84
85     k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
86       kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
87       kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
88       kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
89       kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
90       kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n'
91     end
92   end
93
94   ## there are a couple important instance variables we hold to format
95   ## the thread and to provide line-based functionality. @layout is a
96   ## map from Messages to MessageLayouts, and @chunk_layout from
97   ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
98   ## Message objects.  @chunk_lines is a map from row #s to Chunk
99   ## objects. @person_lines is a map from row #s to Person objects.
100
101   def initialize thread, hidden_labels=[], index_mode=nil
102     super()
103     @thread = thread
104     @hidden_labels = hidden_labels
105
106     ## used for dispatch-and-next
107     @index_mode = index_mode
108     @dying = false
109
110     @layout = SavingHash.new { MessageLayout.new }
111     @chunk_layout = SavingHash.new { ChunkLayout.new }
112     earliest, latest = nil, nil
113     latest_date = nil
114     altcolor = false
115
116     @thread.each do |m, d, p|
117       next unless m
118       earliest ||= m
119       @layout[m].state = initial_state_for m
120       @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
121       @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
122       @layout[m].orig_new = m.has_label? :read
123       altcolor = !altcolor
124       if latest_date.nil? || m.date > latest_date
125         latest_date = m.date
126         latest = m
127       end
128     end
129
130     @layout[latest].state = :open if @layout[latest].state == :closed
131     @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
132
133     @thread.remove_label :unread
134     regen_text
135   end
136
137   def draw_line ln, opts={}
138     if ln == curpos
139       super ln, :highlight => true
140     else
141       super
142     end
143   end
144   def lines; @text.length; end
145   def [] i; @text[i]; end
146
147   def show_header
148     m = @message_lines[curpos] or return
149     BufferManager.spawn_unless_exists("Full header for #{m.id}") do
150       TextMode.new m.raw_header
151     end
152   end
153
154   def show_message
155     m = @message_lines[curpos] or return
156     BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
157       TextMode.new m.raw_message
158     end
159   end
160
161   def toggle_detailed_header
162     m = @message_lines[curpos] or return
163     @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
164     update
165   end
166
167   def reply type_arg=nil
168     m = @message_lines[curpos] or return
169     mode = ReplyMode.new m, type_arg
170     BufferManager.spawn "Reply to #{m.subj}", mode
171   end
172
173   def reply_all; reply :all; end
174
175   def subscribe_to_list
176     m = @message_lines[curpos] or return
177     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
178       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
179     else
180       BufferManager.flash "Can't find List-Subscribe header for this message."
181     end
182   end
183
184   def unsubscribe_from_list
185     m = @message_lines[curpos] or return
186     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
187       ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
188     else
189       BufferManager.flash "Can't find List-Unsubscribe header for this message."
190     end
191   end
192
193   def forward
194     if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
195       ForwardMode.spawn_nicely :attachments => [chunk]
196     elsif(m = @message_lines[curpos])
197       ForwardMode.spawn_nicely :message => m
198     end
199   end
200
201   def bounce
202     m = @message_lines[curpos] or return
203     to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
204
205     defcmd = AccountManager.default_account.sendmail.sub(/\s(\-(ti|it|t))\b/) do |match|
206       case "$1"
207         when '-t' then ''
208         else ' -i'
209       end
210     end
211
212     cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
213           when nil, /^$/ then defcmd
214           else hookcmd
215           end + ' ' + to.map { |t| t.email }.join(' ')
216
217     bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
218
219     if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
220       debug "bounce command: #{cmd}"
221       begin
222         IO.popen(cmd, 'w') do |sm|
223           sm.puts m.raw_message
224         end
225         raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
226       rescue SystemCallError, SendmailCommandFailed => e
227         warn "problem sending mail: #{e.message}"
228         BufferManager.flash "Problem sending mail: #{e.message}"
229       end
230     end
231   end
232
233   include CanAliasContacts
234   def alias
235     p = @person_lines[curpos] or return
236     alias_contact p
237     update
238   end
239
240   def search
241     p = @person_lines[curpos] or return
242     mode = PersonSearchResultsMode.new [p]
243     BufferManager.spawn "Search for #{p.name}", mode
244     mode.load_threads :num => mode.buffer.content_height
245   end    
246
247   def compose
248     p = @person_lines[curpos]
249     if p
250       ComposeMode.spawn_nicely :to_default => p
251     else
252       ComposeMode.spawn_nicely
253     end
254   end    
255
256   def edit_labels
257     reserved_labels = @thread.labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
258     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels
259
260     return unless new_labels
261     @thread.labels = Set.new(reserved_labels) + new_labels
262     new_labels.each { |l| LabelManager << l }
263     update
264     UpdateManager.relay self, :labeled, @thread.first
265   end
266
267   def toggle_starred
268     m = @message_lines[curpos] or return
269     toggle_label m, :starred
270   end
271
272   def toggle_new
273     m = @message_lines[curpos] or return
274     toggle_label m, :unread
275   end
276
277   def toggle_label m, label
278     if m.has_label? label
279       m.remove_label label
280     else
281       m.add_label label
282     end
283     ## TODO: don't recalculate EVERYTHING just to add a stupid little
284     ## star to the display
285     update
286     UpdateManager.relay self, :single_message_labeled, m
287   end
288
289   ## called when someone presses enter when the cursor is highlighting
290   ## a chunk. for expandable chunks (including messages) we toggle
291   ## open/closed state; for viewable chunks (like attachments) we
292   ## view.
293   def activate_chunk
294     chunk = @chunk_lines[curpos] or return
295     if chunk.is_a? Chunk::Text
296       ## if the cursor is over a text region, expand/collapse the
297       ## entire message
298       chunk = @message_lines[curpos]
299     end
300     layout = if chunk.is_a?(Message)
301       @layout[chunk]
302     elsif chunk.expandable?
303       @chunk_layout[chunk]
304     end
305     if layout
306       layout.state = (layout.state != :closed ? :closed : :open)
307       #cursor_down if layout.state == :closed # too annoying
308       update
309     elsif chunk.viewable?
310       view chunk
311     end
312     if chunk.is_a?(Message)
313       jump_to_message chunk
314       jump_to_next_open if layout.state == :closed
315     end
316   end
317
318   def edit_as_new
319     m = @message_lines[curpos] or return
320     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)
321     BufferManager.spawn "edit as new", mode
322     mode.edit_message
323   end
324
325   def save_to_disk
326     chunk = @chunk_lines[curpos] or return
327     case chunk
328     when Chunk::Attachment
329       default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
330       fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
331       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
332     else
333       m = @message_lines[curpos]
334       fn = BufferManager.ask_for_filename :filename, "Save message to file: "
335       return unless fn
336       save_to_file(fn) do |f|
337         m.each_raw_message_line { |l| f.print l }
338       end
339     end
340   end
341
342   def edit_draft
343     m = @message_lines[curpos] or return
344     if m.is_draft?
345       mode = ResumeMode.new m
346       BufferManager.spawn "Edit message", mode
347       BufferManager.kill_buffer self.buffer
348       mode.edit_message
349     else
350       BufferManager.flash "Not a draft message!"
351     end
352   end
353
354   def send_draft
355     m = @message_lines[curpos] or return
356     if m.is_draft?
357       mode = ResumeMode.new m
358       BufferManager.spawn "Send message", mode
359       BufferManager.kill_buffer self.buffer
360       mode.send_message
361     else
362       BufferManager.flash "Not a draft message!"
363     end
364   end
365
366   def jump_to_first_open
367     m = @message_lines[0] or return
368     if @layout[m].state != :closed
369       jump_to_message m#, true
370     else
371       jump_to_next_open #true
372     end
373   end
374
375   def jump_to_next_open force_alignment=nil
376     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
377     m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
378     return unless m
379     while nextm = @layout[m].next
380       break if @layout[nextm].state != :closed
381       m = nextm
382     end
383     jump_to_message nextm, force_alignment if nextm
384   end
385
386   def align_current_message
387     m = @message_lines[curpos] or return
388     jump_to_message m, true
389   end
390
391   def jump_to_prev_open
392     m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
393     return unless m
394     ## jump to the top of the current message if we're in the body;
395     ## otherwise, to the previous message
396     
397     top = @layout[m].top
398     if curpos == top
399       while(prevm = @layout[m].prev)
400         break if @layout[prevm].state != :closed
401         m = prevm
402       end
403       jump_to_message prevm if prevm
404     else
405       jump_to_message m
406     end
407   end
408
409   def jump_to_message m, force_alignment=false
410     l = @layout[m]
411
412     ## boundaries of the message
413     message_left = l.depth * INDENT_SPACES
414     message_right = message_left + l.width
415
416     ## calculate leftmost colum
417     left = if force_alignment # force mode: align exactly
418       message_left
419     else # regular: minimize cursor movement
420       ## leftmost and rightmost are boundaries of all valid left-column
421       ## alignments.
422       leftmost = [message_left, message_right - buffer.content_width + 1].min
423       rightmost = message_left
424       leftcol.clamp(leftmost, rightmost)
425     end
426
427     jump_to_line l.top    # move vertically
428     jump_to_col left      # move horizontally
429     set_cursor_pos l.top  # set cursor pos
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