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