]> git.cworth.org Git - sup/blob - lib/sup/modes/thread-index-mode.rb
bugfix: killed threads shouldn't be hidden a priori by thread-index-mode
[sup] / lib / sup / modes / thread-index-mode.rb
1 require 'thread'
2 module Redwood
3
4 ## subclasses should implement load_threads
5 class ThreadIndexMode < LineCursorMode
6   DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
7   FROM_WIDTH = 15
8   LOAD_MORE_THREAD_NUM = 20
9
10   register_keymap do |k|
11     k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
12     k.add :reload, "Discard threads and reload", 'D'
13     k.add :toggle_archived, "Toggle archived status", 'a'
14     k.add :toggle_starred, "Star or unstar all messages in thread", '*'
15     k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
16     k.add :edit_labels, "Edit or add labels for a thread", 'l'
17     k.add :edit_message, "Edit message (drafts only)", 'e'
18     k.add :mark_as_spam, "Mark thread as spam", 'S'
19     k.add :delete, "Mark thread for deletion", 'd'
20     k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
21     k.add :save, "Save changes now", '$'
22     k.add :jump_to_next_new, "Jump to next new thread", :tab
23     k.add :reply, "Reply to a thread", 'r'
24     k.add :forward, "Forward a thread", 'f'
25     k.add :toggle_tagged, "Tag/untag current line", 't'
26     k.add :apply_to_tagged, "Apply next command to all tagged threads", ';'
27   end
28
29   def initialize required_labels=[], hidden_labels=[]
30     super()
31     @load_thread = nil
32     @required_labels = required_labels
33     @hidden_labels = hidden_labels + LabelManager::HIDDEN_LABELS
34     @date_width = DATE_WIDTH
35     @from_width = FROM_WIDTH
36     @size_width = nil
37     @last_load_more_size = nil
38     
39     @tags = Tagger.new self
40     
41     initialize_threads
42     update
43
44     UpdateManager.register self
45
46     to_load_more do |size|
47       next if @last_load_more_size == 0
48       load_threads :num => size,
49                    :when_done => lambda { |num| @last_load_more_size = num }
50       sleep 1.0 # give 'em a chance to load
51     end
52   end
53
54   def lines; @text.length; end
55   def [] i; @text[i]; end
56   def contains_thread? t; !@lines[t].nil?; end
57
58   def reload
59     drop_all_threads
60     BufferManager.draw_screen
61     load_threads :num => buffer.content_height
62   end
63
64   ## open up a thread view window
65   def select t=nil
66     t ||= @threads[curpos] or return
67
68     ## TODO: don't regen text completely
69     Redwood::reporting_thread do
70       BufferManager.say("Loading message bodies...") do |sid|
71         t.each { |m, *o| m.load_from_source! if m }
72       end
73       mode = ThreadViewMode.new t, @hidden_labels
74       BufferManager.spawn t.subj, mode
75       BufferManager.draw_screen
76       mode.jump_to_first_open
77       BufferManager.draw_screen # lame TODO: make this unnecessary
78       ## the first draw_screen is needed before topline and botline
79       ## are set, and the second to show the cursor having moved
80
81       t.remove_label :unread
82       update_text_for_line curpos
83       UpdateManager.relay self, :read, t
84     end
85   end
86
87   def multi_select threads
88     threads.each { |t| select t }
89   end
90   
91   def handle_starred_update sender, m
92     t = @ts.thread_for(m) or return
93     l = @lines[t] or return
94     update_text_for_line l
95     BufferManager.draw_screen
96   end
97
98   def handle_read_update sender, t
99     l = @lines[t] or return
100     update_text_for_line @lines[t]
101     BufferManager.draw_screen
102   end
103
104   def handle_archived_update *a; handle_read_update(*a); end
105
106   ## overwrite me!
107   def is_relevant? m; false; end
108
109   def handle_add_update sender, m
110     if is_relevant?(m) || @ts.is_relevant?(m)
111       @ts.load_thread_for_message m
112       update
113       BufferManager.draw_screen
114     end
115   end
116
117   def handle_delete_update sender, mid
118     if @ts.contains_id? mid
119       @ts.remove mid
120       update
121       BufferManager.draw_screen
122     end
123   end
124
125   def update
126     ## let's see you do THIS in python
127     @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| t.date }.reverse
128     @size_width = (@threads.map { |t| t.size }.max || 0).num_digits
129     regen_text
130   end
131
132   def edit_message
133     return unless(t = @threads[curpos])
134     message, *crap = t.find { |m, *o| m.has_label? :draft }
135     if message
136       mode = ResumeMode.new message
137       BufferManager.spawn "Edit message", mode
138     else
139       BufferManager.flash "Not a draft message!"
140     end
141   end
142
143   def actually_toggle_starred t
144     if t.has_label? :starred # if ANY message has a star
145       t.remove_label :starred # remove from all
146     else
147       t.first.add_label :starred # add only to first
148     end
149   end  
150
151   def toggle_starred 
152     t = @threads[curpos] or return
153     actually_toggle_starred t
154     update_text_for_line curpos
155     cursor_down
156   end
157
158   def multi_toggle_starred threads
159     threads.each { |t| actually_toggle_starred t }
160     regen_text
161   end
162
163   def actually_toggle_archived t
164     if t.has_label? :inbox
165       t.remove_label :inbox
166       UpdateManager.relay self, :archived, t
167     else
168       t.apply_label :inbox
169       UpdateManager.relay self, :unarchived, t
170     end
171   end
172
173   def toggle_archived 
174     t = @threads[curpos] or return
175     actually_toggle_archived t
176     update_text_for_line curpos
177   end
178
179   def multi_toggle_archived threads
180     threads.each { |t| actually_toggle_archived t }
181     regen_text
182   end
183
184   def toggle_new
185     t = @threads[curpos] or return
186     t.toggle_label :unread
187     update_text_for_line curpos
188     cursor_down
189   end
190
191   def multi_toggle_new threads
192     threads.each { |t| t.toggle_label :unread }
193     regen_text
194   end
195
196   def multi_toggle_tagged threads
197     @tags.drop_all_tags
198     regen_text
199   end
200
201   def jump_to_next_new
202     n = ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread }
203     n = (0 ... curpos).find { |i| @threads[i].has_label? :unread } unless n
204     if n
205       set_cursor_pos n
206     else
207       BufferManager.flash "No new messages"
208     end
209   end
210
211   def mark_as_spam
212     t = @threads[curpos] or return
213     multi_mark_as_spam [t]
214   end
215
216   def multi_mark_as_spam threads
217     threads.each do |t|
218       t.toggle_label :spam
219       hide_thread t
220     end
221     regen_text
222   end
223
224   def delete
225     t = @threads[curpos] or return
226     multi_delete [t]
227   end
228
229   def multi_delete threads
230     threads.each do |t|
231       t.toggle_label :deleted
232       hide_thread t
233     end
234     regen_text
235   end
236
237   def kill
238     t = @threads[curpos] or return
239     multi_kill [t]
240   end
241
242   def multi_kill threads
243     threads.each do |t|
244       t.apply_label :killed
245       hide_thread t
246     end
247     regen_text
248   end
249
250   def save
251     dirty_threads = (@threads + @hidden_threads.keys).select { |t| t.dirty? }
252     return if dirty_threads.empty?
253
254     BufferManager.say("Saving threads...") do |say_id|
255       dirty_threads.each_with_index do |t, i|
256         BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
257         t.save Index
258       end
259     end
260   end
261
262   def cleanup
263     UpdateManager.unregister self
264
265     if @load_thread
266       @load_thread.kill 
267       BufferManager.clear @mbid if @mbid
268       sleep 0.1 # TODO: necessary?
269       BufferManager.erase_flash
270     end
271     save
272     super
273   end
274
275   def toggle_tagged
276     t = @threads[curpos] or return
277     @tags.toggle_tag_for t
278     update_text_for_line curpos
279     cursor_down
280   end
281
282   def apply_to_tagged; @tags.apply_to_tagged; end
283
284   def edit_labels
285     thread = @threads[curpos] or return
286     speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
287     keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
288     label_string = modifyl.join(" ")
289     label_string += " " unless label_string.empty?
290
291     answer = BufferManager.ask :edit_labels, "edit labels: ", label_string
292     return unless answer
293     user_labels = answer.split(/\s+/).map { |l| l.intern }
294     
295     hl = user_labels.select { |l| speciall.member? l }
296     if hl.empty?
297       thread.labels = keepl + user_labels
298       user_labels.each { |l| LabelManager << l }
299     else
300       BufferManager.flash "'#{hl}' is a reserved label!"
301     end
302     update_text_for_line curpos
303   end
304
305   def multi_edit_labels threads
306     answer = BufferManager.ask :add_labels, "add labels: "
307     return unless answer
308     user_labels = answer.split(/\s+/).map { |l| l.intern }
309     
310     hl = user_labels.select { |l| @hidden_labels.member? l }
311     if hl.empty?
312       threads.each { |t| user_labels.each { |l| t.apply_label l } }
313       user_labels.each { |l| LabelManager << l }
314     else
315       BufferManager.flash "'#{hl}' is a reserved label!"
316     end
317     regen_text
318   end
319
320   def reply
321     t = @threads[curpos] or return
322     m = t.latest_message
323     return if m.nil? # probably won't happen
324     m.load_from_source!
325     mode = ReplyMode.new m
326     BufferManager.spawn "Reply to #{m.subj}", mode
327   end
328
329   def forward
330     t = @threads[curpos] or return
331     m = t.latest_message
332     return if m.nil? # probably won't happen
333     m.load_from_source!
334     mode = ForwardMode.new m
335     BufferManager.spawn "Forward of #{m.subj}", mode
336     mode.edit
337   end
338
339   def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
340     return if @load_thread # todo: wrap in mutex
341     @load_thread = Redwood::reporting_thread do
342       num = load_n_threads n, opts
343       opts[:when_done].call(num) if opts[:when_done]
344       @load_thread = nil
345     end
346   end
347
348   def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
349     @mbid = BufferManager.say "Searching for threads..."
350     orig_size = @ts.size
351     last_update = Time.now - 9999 # oh yeah
352     @ts.load_n_threads(@ts.size + n, opts) do |i|
353       BufferManager.say "Loaded #{i} threads...", @mbid
354       if (Time.now - last_update) >= 0.25
355         update
356         BufferManager.draw_screen
357         last_update = Time.now
358       end
359     end
360     @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
361
362     update
363     BufferManager.clear @mbid
364     @mbid = nil
365     BufferManager.draw_screen
366     @ts.size - orig_size
367   end
368
369   def status
370     if (l = lines) == 0
371       "line 0 of 0"
372     else
373       "line #{curpos + 1} of #{l} #{dirty? ? '*modified*' : ''}"
374     end
375   end
376
377 protected
378
379   def cursor_thread; @threads[curpos]; end
380
381   def drop_all_threads
382     @tags.drop_all_tags
383     initialize_threads
384     update
385   end
386
387   def hide_thread t
388     raise "already hidden" if @hidden_threads[t]
389     @hidden_threads[t] = true
390     @threads.delete t
391     @tags.drop_tag_for t
392   end
393
394   def show_thread t
395     if @hidden_threads[t]
396       @hidden_threads.delete t
397     else
398       @ts.add_thread t
399     end
400     update
401   end
402
403   def update_text_for_line l
404     return unless l # not sure why this happens, but it does, occasionally
405     @text[l] = text_for_thread @threads[l]
406     buffer.mark_dirty if buffer
407   end
408
409   def regen_text
410     @text = @threads.map_with_index { |t, i| text_for_thread t }
411     @lines = @threads.map_with_index { |t, i| [t, i] }.to_h
412     buffer.mark_dirty if buffer
413   end
414   
415   def author_text_for_thread t
416     if t.authors.size == 1
417       t.authors.first.mediumname
418     else
419       t.authors.map { |p| AccountManager.is_account?(p) ? "me" : p.shortname }.join ", "
420     end
421   end
422
423   def text_for_thread t
424     date = t.date.to_nice_s(Time.now)
425     from = author_text_for_thread(t)
426     if from.length > @from_width
427       from = from[0 ... (@from_width - 1)]
428       from += "." unless from[-1] == ?\s
429     end
430
431     new = t.has_label?(:unread)
432     starred = t.has_label?(:starred)
433
434     dp = t.direct_participants.any? { |p| AccountManager.is_account? p }
435     p = dp || t.participants.any? { |p| AccountManager.is_account? p }
436
437     base_color =
438       if new
439         :index_new_color
440       elsif starred
441         :index_starred_color
442       else 
443         :index_old_color
444       end
445
446     [ 
447       [:tagged_color, @tags.tagged?(t) ? ">" : " "],
448       [:none, sprintf("%#{@date_width}s", date)],
449       (starred ? [:starred_color, "*"] : [:none, " "]),
450       [base_color, sprintf("%-#{@from_width}s", from)],
451       [:none, t.size == 1 ? " " * (@size_width + 2) : sprintf("(%#{@size_width}d)", t.size)],
452       [:to_me_color, dp ? " >" : (p ? ' +' : "  ")],
453       [base_color, t.subj + (t.subj.empty? ? "" : " ")],
454     ] +
455       (t.labels - @hidden_labels).map { |label| [:label_color, "+#{label} "] } +
456       [[:snippet_color, t.snippet]
457     ]
458   end
459
460   def dirty?; (@hidden_threads.keys + @threads).any? { |t| t.dirty? }; end
461
462 private
463
464   def initialize_threads
465     @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
466     @ts_mutex = Mutex.new
467     @hidden_threads = {}
468   end
469 end
470
471 end