heathrow 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +58 -0
  3. data/README.md +205 -0
  4. data/bin/heathrow +42 -0
  5. data/bin/heathrowd +283 -0
  6. data/docs/ARCHITECTURE.md +1172 -0
  7. data/docs/DATABASE_SCHEMA.md +685 -0
  8. data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
  9. data/docs/DISCORD_SETUP.md +142 -0
  10. data/docs/GMAIL_OAUTH_SETUP.md +120 -0
  11. data/docs/PLUGIN_SYSTEM.md +1370 -0
  12. data/docs/PROJECT_PLAN.md +1022 -0
  13. data/docs/README.md +417 -0
  14. data/docs/REDDIT_SETUP.md +174 -0
  15. data/docs/REPLY_FORWARD.md +182 -0
  16. data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
  17. data/heathrow.gemspec +34 -0
  18. data/heathrowd.service +21 -0
  19. data/img/heathrow.svg +95 -0
  20. data/img/rss_threaded.png +0 -0
  21. data/img/sources.png +0 -0
  22. data/lib/heathrow/address_book.rb +42 -0
  23. data/lib/heathrow/config.rb +332 -0
  24. data/lib/heathrow/database.rb +731 -0
  25. data/lib/heathrow/database_new.rb +392 -0
  26. data/lib/heathrow/event_bus.rb +175 -0
  27. data/lib/heathrow/logger.rb +122 -0
  28. data/lib/heathrow/message.rb +176 -0
  29. data/lib/heathrow/message_composer.rb +399 -0
  30. data/lib/heathrow/message_organizer.rb +774 -0
  31. data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
  32. data/lib/heathrow/notmuch.rb +45 -0
  33. data/lib/heathrow/oauth2_smtp.rb +254 -0
  34. data/lib/heathrow/plugin/base.rb +212 -0
  35. data/lib/heathrow/plugin_manager.rb +141 -0
  36. data/lib/heathrow/poller.rb +93 -0
  37. data/lib/heathrow/smtp_sender.rb +204 -0
  38. data/lib/heathrow/source.rb +39 -0
  39. data/lib/heathrow/sources/base.rb +74 -0
  40. data/lib/heathrow/sources/discord.rb +357 -0
  41. data/lib/heathrow/sources/gmail.rb +294 -0
  42. data/lib/heathrow/sources/imap.rb +198 -0
  43. data/lib/heathrow/sources/instagram.rb +307 -0
  44. data/lib/heathrow/sources/instagram_fetch.py +101 -0
  45. data/lib/heathrow/sources/instagram_send.py +55 -0
  46. data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
  47. data/lib/heathrow/sources/maildir.rb +606 -0
  48. data/lib/heathrow/sources/messenger.rb +212 -0
  49. data/lib/heathrow/sources/messenger_fetch.js +297 -0
  50. data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
  51. data/lib/heathrow/sources/messenger_send.js +32 -0
  52. data/lib/heathrow/sources/messenger_send.py +100 -0
  53. data/lib/heathrow/sources/reddit.rb +461 -0
  54. data/lib/heathrow/sources/rss.rb +299 -0
  55. data/lib/heathrow/sources/slack.rb +375 -0
  56. data/lib/heathrow/sources/source_manager.rb +328 -0
  57. data/lib/heathrow/sources/telegram.rb +498 -0
  58. data/lib/heathrow/sources/webpage.rb +207 -0
  59. data/lib/heathrow/sources/weechat.rb +479 -0
  60. data/lib/heathrow/sources/whatsapp.rb +474 -0
  61. data/lib/heathrow/ui/application.rb +8098 -0
  62. data/lib/heathrow/ui/navigation.rb +8 -0
  63. data/lib/heathrow/ui/panes.rb +8 -0
  64. data/lib/heathrow/ui/source_wizard.rb +567 -0
  65. data/lib/heathrow/ui/threaded_view.rb +780 -0
  66. data/lib/heathrow/ui/views.rb +8 -0
  67. data/lib/heathrow/version.rb +3 -0
  68. data/lib/heathrow/wizards/discord_wizard.rb +193 -0
  69. data/lib/heathrow/wizards/slack_wizard.rb +140 -0
  70. data/lib/heathrow.rb +55 -0
  71. metadata +147 -0
@@ -0,0 +1,780 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ module Heathrow
5
+ module UI
6
+ # Threaded message view with collapsible threads
7
+ module ThreadedView
8
+ # Thread state tracking
9
+ def initialize_threading
10
+ @thread_collapsed = {} # Track which threads are collapsed
11
+ @channel_collapsed = {} # Track which channels are collapsed
12
+ @dm_section_collapsed = true # DM section state - START COLLAPSED (single section mode)
13
+ @dm_collapsed = {} # Per-conversation DM collapse tracking
14
+ @show_threaded = true # Toggle between flat and threaded view
15
+ @group_by_folder = false # Toggle between threaded and folder grouping
16
+ @thread_indent = " " # Indentation for thread replies
17
+ @organizer = nil
18
+ @base_messages = nil # Base messages from database (never changes during threading)
19
+ @display_messages = [] # Messages currently displayed (including headers)
20
+ @threading_initialized = false # Track if we've started threading
21
+ @all_start_collapsed = true # Start with everything collapsed
22
+ @section_order = nil # Custom section order (array of names)
23
+ @view_thread_modes = {} # Per-view threading mode: key => {threaded:, folder:}
24
+ end
25
+
26
+ # Toggle between flat and threaded view
27
+ def toggle_thread_view
28
+ @show_threaded = !@show_threaded
29
+ @group_by_folder = false # Disable folder grouping when switching to threaded
30
+ organize_current_messages
31
+ render_all
32
+ end
33
+
34
+ # Toggle folder grouping view
35
+ def toggle_folder_view
36
+ @group_by_folder = !@group_by_folder
37
+ if @group_by_folder
38
+ @show_threaded = true # Enable threaded view for folder grouping to work
39
+ end
40
+ reset_threading # Force re-organization
41
+ organize_current_messages(force_reinit: true)
42
+ render_all
43
+ end
44
+
45
+ # Cycle: flat → threaded → folder-grouped → flat
46
+ def cycle_view_mode
47
+ if !@show_threaded
48
+ # flat → threaded
49
+ @show_threaded = true
50
+ @group_by_folder = false
51
+ set_feedback("Threaded view", 156, 2)
52
+ elsif @show_threaded && !@group_by_folder
53
+ # threaded → folder-grouped
54
+ @group_by_folder = true
55
+ set_feedback("Folder-grouped view", 156, 2)
56
+ else
57
+ # folder-grouped → flat
58
+ @show_threaded = false
59
+ @group_by_folder = false
60
+ set_feedback("Flat view", 156, 2)
61
+ end
62
+ save_view_thread_mode
63
+ reset_threading
64
+ organize_current_messages(true) if @show_threaded
65
+ render_all
66
+ end
67
+
68
+ # Save current threading mode for the active view (persistent)
69
+ def save_view_thread_mode
70
+ return unless @current_view
71
+ @view_thread_modes[@current_view] = { threaded: @show_threaded, folder: @group_by_folder }
72
+
73
+ # Persist to DB
74
+ view = @views[@current_view]
75
+ if view && view[:filters].is_a?(Hash)
76
+ view[:filters]['view_thread_mode'] = thread_mode_key
77
+ @db.execute("UPDATE views SET filters = ?, updated_at = ? WHERE id = ?",
78
+ [JSON.generate(view[:filters]), Time.now.to_i, view[:id]])
79
+ else
80
+ # Built-in views (A, N): store in settings table
81
+ @db.execute(
82
+ "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
83
+ ["thread_mode_#{@current_view}", thread_mode_key, Time.now.to_i]
84
+ )
85
+ end
86
+ end
87
+
88
+ # Restore threading mode for the active view
89
+ def restore_view_thread_mode
90
+ return false unless @current_view
91
+
92
+ # Check in-memory cache first
93
+ if @view_thread_modes.key?(@current_view)
94
+ mode = @view_thread_modes[@current_view]
95
+ @show_threaded = mode[:threaded]
96
+ @group_by_folder = mode[:folder]
97
+ return true
98
+ end
99
+
100
+ # Load from DB
101
+ mode_key = nil
102
+ view = @views[@current_view]
103
+ if view && view[:filters].is_a?(Hash)
104
+ mode_key = view[:filters]['view_thread_mode']
105
+ end
106
+ unless mode_key
107
+ row = @db.db.get_first_row(
108
+ "SELECT value FROM settings WHERE key = ?",
109
+ ["thread_mode_#{@current_view}"]
110
+ )
111
+ mode_key = row && row['value']
112
+ end
113
+ return false unless mode_key
114
+
115
+ apply_thread_mode_key(mode_key)
116
+ @view_thread_modes[@current_view] = { threaded: @show_threaded, folder: @group_by_folder }
117
+ true
118
+ end
119
+
120
+ # Convert current mode to a string key
121
+ def thread_mode_key
122
+ if !@show_threaded
123
+ 'flat'
124
+ elsif @group_by_folder
125
+ 'folders'
126
+ else
127
+ 'threaded'
128
+ end
129
+ end
130
+
131
+ # Apply a mode from a string key
132
+ def apply_thread_mode_key(key)
133
+ case key
134
+ when 'flat'
135
+ @show_threaded = false
136
+ @group_by_folder = false
137
+ when 'folders'
138
+ @show_threaded = true
139
+ @group_by_folder = true
140
+ else # 'threaded'
141
+ @show_threaded = true
142
+ @group_by_folder = false
143
+ end
144
+ end
145
+
146
+ # Short label for current threading mode
147
+ def thread_mode_label
148
+ if !@show_threaded
149
+ "Flat"
150
+ elsif @group_by_folder
151
+ "Folders"
152
+ else
153
+ "Threaded"
154
+ end
155
+ end
156
+
157
+ # Reset threading state when messages change
158
+ def reset_threading(preserve_collapsed_state = false)
159
+ @base_messages = nil
160
+ @organizer = nil
161
+ @threading_initialized = false
162
+ @display_messages = []
163
+ @scroll_offset = 0 # Reset scroll position
164
+
165
+ # Preserve or reset collapsed states
166
+ if !preserve_collapsed_state
167
+ # Reset collapsed states to start collapsed
168
+ @thread_collapsed = {}
169
+ @channel_collapsed = {}
170
+ @dm_section_collapsed = true
171
+ @dm_collapsed = {}
172
+ @section_order = nil
173
+ end
174
+ # If preserve_collapsed_state is true, keep existing collapsed states
175
+ end
176
+
177
+ # Get the current message for navigation (works with both flat and threaded views)
178
+ def current_message_for_navigation
179
+ if @show_threaded && !@display_messages.empty?
180
+ @display_messages[@index]
181
+ else
182
+ @filtered_messages[@index]
183
+ end
184
+ end
185
+
186
+ # Get the size of the current message array for navigation
187
+ def filtered_messages_size
188
+ if @show_threaded && !@display_messages.empty?
189
+ @display_messages.size
190
+ else
191
+ @filtered_messages.size
192
+ end
193
+ end
194
+
195
+ # Organize messages for current view
196
+ def organize_current_messages(force_reinit = false)
197
+ return unless @show_threaded
198
+
199
+ # Only organize once per message set - capture the base messages on first run
200
+ # OR if we're forcing reinitialization after a sort change
201
+ if !@threading_initialized || force_reinit
202
+ @base_messages = @filtered_messages.dup
203
+ @threading_initialized = true
204
+
205
+ File.open('/tmp/heathrow_debug.log', 'a') do |f|
206
+ f.puts "THREADING: Creating organizer with #{@base_messages.size} base messages (#{force_reinit ? 'forced reinit' : 'first time'})"
207
+ end if ENV['DEBUG']
208
+
209
+ require_relative '../message_organizer'
210
+ @organizer = MessageOrganizer.new(@base_messages, @db, group_by_folder: @group_by_folder)
211
+ end
212
+ end
213
+
214
+ # Toggle collapse state of current item
215
+ def toggle_collapse
216
+ return unless @show_threaded && @organizer
217
+
218
+ msg = current_message_for_navigation
219
+ return unless msg
220
+
221
+ # Determine what type of item this is
222
+ if msg['is_channel_header']
223
+ # Toggle channel collapse
224
+ @channel_collapsed[msg['channel_id']] = !@channel_collapsed[msg['channel_id']]
225
+ elsif msg['is_thread_header']
226
+ # Toggle thread collapse
227
+ @thread_collapsed[msg['thread_id']] = !@thread_collapsed[msg['thread_id']]
228
+ elsif msg['is_dm_header']
229
+ # Toggle DM section (per-conversation or single)
230
+ dm_key = msg['channel_id']
231
+ if @dm_collapsed.key?(dm_key) || @sort_order == 'conversation'
232
+ @dm_collapsed[dm_key] = !@dm_collapsed.fetch(dm_key, true)
233
+ else
234
+ @dm_section_collapsed = !@dm_section_collapsed
235
+ end
236
+ elsif msg['thread_id']
237
+ # Toggle the thread this message belongs to
238
+ @thread_collapsed[msg['thread_id']] = !@thread_collapsed[msg['thread_id']]
239
+ end
240
+
241
+ # Re-render
242
+ render_message_list_threaded
243
+ end
244
+
245
+ # Move current section up or down in the list
246
+ def move_section(direction)
247
+ return unless @show_threaded && @organizer
248
+
249
+ msg = current_message_for_navigation
250
+ return unless msg
251
+ # Allow moving channel headers and thread headers
252
+ return unless msg['is_channel_header'] || msg['is_thread_header']
253
+
254
+ section_name = msg['channel_id'] || msg['thread_id']
255
+ return unless section_name
256
+
257
+ # Build section order from current organized view if not set
258
+ organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
259
+ @section_order ||= organized.map { |s| s[:name] || s[:subject] }
260
+
261
+ idx = @section_order.index(section_name)
262
+ return unless idx
263
+
264
+ new_idx = idx + direction
265
+ return if new_idx < 0 || new_idx >= @section_order.size
266
+
267
+ # Swap
268
+ @section_order[idx], @section_order[new_idx] = @section_order[new_idx], @section_order[idx]
269
+
270
+ # Persist to view config
271
+ save_section_order
272
+
273
+ # Re-render and find new cursor position
274
+ render_message_list_threaded
275
+ key = msg['is_channel_header'] ? 'channel_id' : 'thread_id'
276
+ new_display_idx = @display_messages.index { |m| m[key] == section_name }
277
+ @index = new_display_idx if new_display_idx
278
+ render_message_list_threaded
279
+ end
280
+
281
+ # Save section order into the current view's filters in the database
282
+ def save_section_order
283
+ return unless @current_view && @views[@current_view] && @section_order
284
+ view = @views[@current_view]
285
+ view[:filters] ||= {}
286
+ view[:filters]['section_order'] = @section_order
287
+
288
+ # Persist to database
289
+ if view[:id]
290
+ @db.save_view({
291
+ id: view[:id],
292
+ name: view[:name],
293
+ key_binding: @current_view,
294
+ filters: view[:filters],
295
+ sort_order: view[:sort_order] || 'timestamp DESC'
296
+ })
297
+ end
298
+ end
299
+
300
+ # Render threaded message list
301
+ def render_message_list_threaded
302
+ return render_message_list unless @show_threaded && @organizer
303
+
304
+ lines = []
305
+ visible_messages = []
306
+ current_index = 0
307
+ @scroll_offset ||= 0 # Track scroll position
308
+
309
+ organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
310
+
311
+ # Apply custom section order if set
312
+ if @section_order && !@section_order.empty?
313
+ order_map = {}
314
+ @section_order.each_with_index { |name, i| order_map[name] = i }
315
+ organized.sort_by! { |s| order_map[s[:name] || s[:subject]] || 9999 }
316
+ end
317
+
318
+ organized.each do |section|
319
+ case section[:type]
320
+ when 'channel'
321
+ # Channel header
322
+ header_msg = create_section_header(section, 'channel')
323
+
324
+ # Initialize collapse state if not set
325
+ if !@channel_collapsed.key?(section[:name])
326
+ @channel_collapsed[section[:name]] = @all_start_collapsed
327
+ end
328
+
329
+ # Store line index for scrolling
330
+ lines << format_channel_header(section, current_index == @index)
331
+ visible_messages << header_msg
332
+ current_index += 1
333
+
334
+ # Channel messages if not collapsed
335
+ unless @channel_collapsed[section[:name]]
336
+ # Set current channel ID for messages
337
+ @current_channel_id = section[:name]
338
+ render_channel_messages(section[:messages], lines, visible_messages, current_index, section[:source])
339
+ current_index += section[:messages].size
340
+ end
341
+
342
+ when 'dm_section'
343
+ # DM section header
344
+ dm_key = section[:name]
345
+ # Use per-conversation collapse if in conversation sort, otherwise single toggle
346
+ collapsed = if @sort_order == 'conversation'
347
+ @dm_collapsed.fetch(dm_key, true) # Default collapsed
348
+ else
349
+ @dm_section_collapsed
350
+ end
351
+ header_msg = create_section_header(section, 'dm')
352
+ lines << format_dm_header(section, current_index == @index, collapsed: collapsed)
353
+ visible_messages << header_msg
354
+ current_index += 1
355
+
356
+ # DMs if not collapsed
357
+ unless collapsed
358
+ section[:messages].each do |msg|
359
+ lines << format_dm_message(msg, current_index == @index)
360
+ visible_messages << msg
361
+ current_index += 1
362
+ end
363
+ end
364
+
365
+ when 'thread'
366
+ if section[:messages] && section[:messages].size == 1
367
+ # Single message — show as flat item, no header
368
+ msg = section[:messages].first
369
+ lines << format_thread_message(msg, current_index == @index, "")
370
+ visible_messages << msg
371
+ current_index += 1
372
+ else
373
+ # Multi-message thread — collapsible header
374
+ header_msg = create_section_header(section, 'thread')
375
+
376
+ if !@thread_collapsed.key?(section[:subject])
377
+ @thread_collapsed[section[:subject]] = @all_start_collapsed
378
+ end
379
+
380
+ lines << format_thread_header(section, current_index == @index)
381
+ visible_messages << header_msg
382
+ current_index += 1
383
+
384
+ unless @thread_collapsed[section[:subject]]
385
+ render_thread_messages(section[:messages], lines, visible_messages, current_index)
386
+ current_index += section[:messages].size
387
+ end
388
+ end
389
+ end
390
+ end
391
+
392
+ # Store display messages for navigation (don't overwrite @filtered_messages)
393
+ @display_messages = visible_messages
394
+
395
+ # Restore index by message ID after a background refresh rebuilt the list.
396
+ # @pending_restore_id is set by the pending_view_refresh handler because
397
+ # @display_messages is empty at that point (reset_threading clears it).
398
+ if @pending_restore_id
399
+ new_idx = @display_messages.index { |m| m['id'] == @pending_restore_id }
400
+ @pending_restore_id = nil
401
+ if new_idx && new_idx != @index
402
+ @index = new_idx
403
+ return render_message_list_threaded # Re-render with correct highlight
404
+ end
405
+ end
406
+
407
+ File.open('/tmp/heathrow_debug.log', 'a') do |f|
408
+ f.puts "THREADING: Created #{@display_messages.size} display messages for navigation"
409
+ end if ENV['DEBUG']
410
+
411
+ # Give full text to rcurses and use its scrolling with markers
412
+ @panes[:left].scroll = true
413
+ new_text = lines.join("\n")
414
+
415
+ # Calculate scroll position to keep current item visible
416
+ pane_height = @panes[:left].h - 2
417
+ scrolloff = 3
418
+ old_ix = @panes[:left].ix
419
+
420
+ if @index < @panes[:left].ix + scrolloff
421
+ @panes[:left].ix = [@index - scrolloff, 0].max
422
+ elsif @index > @panes[:left].ix + pane_height - scrolloff - 1
423
+ max_ix = [lines.size - pane_height, 0].max
424
+ @panes[:left].ix = [@index - pane_height + scrolloff + 1, max_ix].min
425
+ end
426
+
427
+ @panes[:left].text = new_text
428
+ @panes[:left].refresh
429
+ end
430
+
431
+ # Get date range string for a section's messages
432
+ def section_date_range(messages)
433
+ return "" if messages.nil? || messages.empty?
434
+ timestamps = messages.map { |m| m['timestamp'] }.compact
435
+ return "" if timestamps.empty?
436
+ times = timestamps.map { |t|
437
+ if t.is_a?(Integer) || t.to_s.match?(/^\d+$/)
438
+ Time.at(t.to_i)
439
+ else
440
+ Time.parse(t.to_s) rescue nil
441
+ end
442
+ }.compact
443
+ return "" if times.empty?
444
+ oldest = times.min
445
+ newest = times.max
446
+ fmt = @date_format || '%b %-d'
447
+ if oldest.to_date == newest.to_date
448
+ newest.strftime(fmt)
449
+ else
450
+ "#{oldest.strftime(fmt)} – #{newest.strftime(fmt)}"
451
+ end
452
+ end
453
+
454
+ # Shared section header formatter
455
+ def format_section_header(display_name, collapsed, color, section, selected, source_icon: nil)
456
+ icon = collapsed ? "▶" : "▼"
457
+ unread = section[:unread_count].to_i > 0 ? " (#{section[:unread_count]})" : ""
458
+ dates = section_date_range(section[:messages])
459
+ date_suffix = dates.empty? ? "" : " #{dates}"
460
+
461
+ pane_width = @panes[:left].w - 5
462
+ prefix = source_icon ? "#{icon} #{source_icon} " : "#{icon} "
463
+ used_space = prefix.length + unread.length + date_suffix.length
464
+ available = pane_width - used_space
465
+
466
+ if display_name.length > available && available > 3
467
+ display_name = display_name[0..(available-2)] + "…"
468
+ end
469
+
470
+ name_part = "#{display_name}#{unread}"
471
+
472
+ if selected
473
+ prefix.b.fg(color) + name_part.u.b.fg(color) + date_suffix.fg(245)
474
+ elsif section[:unread_count].to_i > 0
475
+ prefix.b.fg(color) + name_part.b.fg(color) + date_suffix.fg(245)
476
+ else
477
+ (prefix + name_part).fg(color) + date_suffix.fg(245)
478
+ end
479
+ end
480
+
481
+ # Format channel header line
482
+ def format_channel_header(section, selected)
483
+ collapsed = @channel_collapsed[section[:name]]
484
+ display_name = section[:display_name] || section[:name]
485
+ color = get_source_color({'source_type' => section[:source]})
486
+ format_section_header(display_name, collapsed, color, section, selected,
487
+ source_icon: get_source_icon(section[:source]))
488
+ end
489
+
490
+ # Format DM section header
491
+ def format_dm_header(section, selected, collapsed: @dm_section_collapsed)
492
+ dm_text = section[:name] || "Direct Messages"
493
+ format_section_header(dm_text, collapsed, theme[:dm], section, selected, source_icon: "⇔")
494
+ end
495
+
496
+ # Format thread header
497
+ def format_thread_header(section, selected)
498
+ collapsed = @thread_collapsed[section[:subject]]
499
+ subject = section[:subject] || "(no subject)"
500
+ format_section_header(subject, collapsed, theme[:thread], section, selected)
501
+ end
502
+
503
+ # Format channel message
504
+ def render_channel_messages(messages, lines, visible_messages, start_index, section_source = nil)
505
+ messages.each_with_index do |msg, i|
506
+ # Use section source (from organizer) since DB messages don't have source_type
507
+ source_type = section_source || msg['source_type'] || 'unknown'
508
+ lines << format_channel_message(msg, start_index + i == @index, "", source_type)
509
+
510
+ # Don't duplicate the message - just add channel_id directly
511
+ # This ensures updates to is_read status are preserved
512
+ msg['channel_id'] ||= @current_channel_id
513
+ visible_messages << msg
514
+ end
515
+ end
516
+
517
+ # Format thread messages
518
+ def render_thread_messages(messages, lines, visible_messages, start_index)
519
+ messages.each_with_index do |msg, i|
520
+ level = (msg['thread_level'] || 0) + 1 # +1 to indent under thread header
521
+ indent = @thread_indent * level
522
+
523
+ lines << format_thread_message(msg, start_index + i == @index, indent)
524
+ visible_messages << msg
525
+ end
526
+ end
527
+
528
+ # Format a channel message line
529
+ def format_channel_message(msg, selected, indent = "", source_type = nil)
530
+ sender = display_sender(msg)
531
+
532
+ # For RSS, show article title not sender (use passed source_type since DB lacks it)
533
+ if (source_type || msg['source_type']) == 'rss'
534
+ sender = '' # Don't show sender for RSS
535
+ else
536
+ sender = sender[0..14] if sender.length > 15
537
+ end
538
+
539
+ # For Discord/Slack channels, show content not channel name
540
+ content = msg['content'] || ''
541
+
542
+ # Check if subject is a channel name or ID
543
+ if msg['subject'] =~ /^\d+$/ || msg['subject'] =~ /#/
544
+ # It's a channel indicator, show content instead
545
+ display_content = content.gsub(/\n/, ' ')
546
+ elsif msg['subject'] == msg['recipient']
547
+ # Subject is same as recipient (channel name), show content
548
+ display_content = content.gsub(/\n/, ' ')
549
+ else
550
+ # Normal subject, show it
551
+ display_content = msg['subject'] || content
552
+ display_content = display_content.gsub(/\n/, ' ')
553
+ end
554
+
555
+ unread = msg['is_read'].to_i == 0 ? "•" : " "
556
+ color = get_source_color(msg)
557
+
558
+ if sender.empty?
559
+ prefix = "#{unread} "
560
+ else
561
+ prefix = "#{unread} #{sender.ljust(15)} "
562
+ end
563
+
564
+ # Truncate content to fit single line (prefix + 2 for nflag+ind from finalize_line)
565
+ pane_width = @panes[:left].w - 5
566
+ available = pane_width - prefix.length
567
+ if display_content && display_content.length > available && available > 0
568
+ display_content = display_content[0..(available-2)] + "…"
569
+ end
570
+
571
+ finalize_line(msg, selected, prefix, display_content, color)
572
+ end
573
+
574
+ # Format a thread reply
575
+ def format_thread_reply(msg, selected, indent)
576
+ sender = display_sender(msg)
577
+ sender = sender[0..12] if sender.length > 12
578
+
579
+ content = msg['content'] || ''
580
+ content = content.gsub(/\n/, ' ')
581
+
582
+ # Truncate based on pane width
583
+ pane_width = @panes[:left].w - 5
584
+ used_space = 2 + indent.length + 3 + sender.length + 2 # arrow + indent + └─ + sender + :
585
+ available = pane_width - used_space
586
+ if content.length > available && available > 0
587
+ content = content[0..(available-2)] + "…"
588
+ end
589
+
590
+ prefix = "#{indent}└─ #{sender}: "
591
+ finalize_line(msg, selected, prefix, content, 245)
592
+ end
593
+
594
+ # Format a thread message
595
+ def format_thread_message(msg, selected, indent)
596
+ sender = display_sender(msg)
597
+ pane_width = @panes[:left].w - 5
598
+ icon = get_source_icon(msg['source_type'])
599
+
600
+ timestamp = (parse_timestamp(msg['timestamp']) || "").ljust(6)
601
+ sender_max = 15
602
+ sender = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
603
+ child_indent = indent.empty? ? "" : " "
604
+ prefix = "#{child_indent}#{timestamp} #{icon} #{sender} "
605
+
606
+ content = msg['subject'] || msg['content'] || ''
607
+ content = content.gsub(/\n/, ' ')
608
+
609
+ used_space = prefix.length + 1
610
+ available = pane_width - used_space
611
+ if content.length > available && available > 0
612
+ content = content[0..(available-2)] + "…"
613
+ end
614
+
615
+ color = get_source_color(msg)
616
+ finalize_line(msg, selected, prefix, content, color)
617
+ end
618
+
619
+ # Format DM message
620
+ def format_dm_message(msg, selected)
621
+ sender = display_sender(msg)
622
+ platform = get_source_icon(msg['source_type'])
623
+
624
+ # Truncate sender to fixed width
625
+ sender_max = 15
626
+ sender = sender.length > sender_max ? sender[0..sender_max-2] + '…' : sender.ljust(sender_max)
627
+
628
+ content = msg['content'] || ''
629
+ content = content.gsub(/\n/, ' ')
630
+
631
+ unread = msg['is_read'].to_i == 0 ? "•" : " "
632
+ prefix = "#{unread} #{platform} #{sender} "
633
+
634
+ # Truncate content to fit single line
635
+ pane_width = @panes[:left].w - 5
636
+ available = pane_width - prefix.length
637
+ if content.length > available && available > 0
638
+ content = content[0..(available-2)] + "…"
639
+ end
640
+
641
+ color = get_source_color(msg)
642
+ finalize_line(msg, selected, prefix, content, color)
643
+ end
644
+
645
+ # Get source platform icon
646
+ def get_source_icon(source)
647
+ case source.to_s
648
+ when 'discord' then '◆' # Diamond for Discord (no emoji)
649
+ when 'slack' then '#' # Hash remains for Slack
650
+ when 'telegram' then '✈' # Airplane works fine
651
+ when 'whatsapp' then '◉' # Filled circle works fine
652
+ when 'reddit' then '®' # Registered mark for Reddit
653
+ when 'email', 'gmail', 'imap', 'maildir' then '✉' # Letter symbol for email
654
+ when 'rss' then '◈' # Diamond for RSS
655
+ when 'web' then '◎' # Target for web watch
656
+ when 'messenger' then '◉' # Filled circle for Messenger
657
+ when 'instagram' then '◈' # Diamond for Instagram
658
+ when 'weechat' then '⊞' # WeeChat relay
659
+ else '•'
660
+ end
661
+ end
662
+
663
+ # Create a section header message object
664
+ def create_section_header(section, type)
665
+ # Get the timestamp from the most recent message in this section
666
+ latest_timestamp = if section[:messages] && !section[:messages].empty?
667
+ msg_timestamp = section[:messages].first['timestamp']
668
+ # Validate timestamp - some sources have invalid values like "0"
669
+ if msg_timestamp && !msg_timestamp.to_s.empty? && msg_timestamp.to_s != "0"
670
+ begin
671
+ Time.parse(msg_timestamp.to_s)
672
+ msg_timestamp
673
+ rescue
674
+ Time.now.iso8601
675
+ end
676
+ else
677
+ Time.now.iso8601
678
+ end
679
+ else
680
+ Time.now.iso8601
681
+ end
682
+
683
+ {
684
+ 'id' => "header_#{type}_#{section[:name] || Time.now.to_i}",
685
+ 'is_header' => true,
686
+ "is_#{type}_header" => true,
687
+ 'channel_id' => section[:name],
688
+ 'channel_name' => section[:name], # Add for toggle_group_read_status
689
+ 'thread_id' => section[:subject],
690
+ 'subject' => section[:name] || section[:subject],
691
+ 'content' => "#{section[:messages].size} messages",
692
+ 'is_read' => section[:unread_count] == 0 ? 1 : 0,
693
+ 'timestamp' => latest_timestamp,
694
+ 'sender' => '',
695
+ 'source_type' => section[:messages].first&.dig('source_type') || 'unknown',
696
+ 'source_id' => section[:messages].first&.dig('source_id'), # Add source_id from first message
697
+ 'section_messages' => section[:messages] # Store reference to messages for toggle
698
+ }
699
+ end
700
+
701
+ # Shared line formatting: selection arrow, underline on subject only, delete/tag/star indicators
702
+ def finalize_line(msg, selected, prefix_text, subject_text, color, padding = "")
703
+ # Unread flag (like mutt's N)
704
+ nflag = msg['is_read'].to_i == 0 ? "N".fg(226) : " "
705
+ # Replied flag (like mutt's r)
706
+ rflag = msg['replied'].to_i == 1 ? "←".fg(45) : " "
707
+ tag_color = theme[:tag] || 14
708
+ star_color = theme[:star] || 226
709
+
710
+ ind = if @delete_marked&.include?(msg['id'])
711
+ "D".fg(88)
712
+ elsif @tagged_messages&.include?(msg['id'])
713
+ "•".fg(tag_color)
714
+ elsif msg['is_starred'] == 1
715
+ "★".fg(star_color)
716
+ elsif msg['attachments'].is_a?(Array) && !msg['attachments'].empty? && !prefix_text.include?("₊")
717
+ "₊".fg(208)
718
+ else
719
+ " "
720
+ end
721
+
722
+ flags = nflag + rflag + ind
723
+ content = prefix_text + subject_text
724
+ if @delete_marked&.include?(msg['id'])
725
+ if selected
726
+ flags + content.u.fg(88) + padding
727
+ else
728
+ flags + content.fg(88) + padding
729
+ end
730
+ elsif @tagged_messages&.include?(msg['id'])
731
+ if selected
732
+ lead = content[/\A */]
733
+ rest = content[lead.length..]
734
+ flags + lead.b.fg(tag_color) + rest.u.b.fg(tag_color) + padding
735
+ else
736
+ flags + content.fg(tag_color) + padding
737
+ end
738
+ elsif msg['is_starred'] == 1
739
+ if selected
740
+ lead = content[/\A */]
741
+ rest = content[lead.length..]
742
+ flags + lead.b.fg(star_color) + rest.u.b.fg(star_color) + padding
743
+ else
744
+ flags + content.fg(star_color) + padding
745
+ end
746
+ elsif selected
747
+ lead = content[/\A */]
748
+ rest = content[lead.length..]
749
+ flags + lead.b.fg(color) + rest.u.b.fg(color) + padding
750
+ elsif msg['is_read'].to_i == 0
751
+ flags + content.b.fg(color) + padding
752
+ else
753
+ flags + content.fg(color) + padding
754
+ end
755
+ end
756
+
757
+ # Format timestamp
758
+ def format_time(timestamp)
759
+ return "" unless timestamp
760
+
761
+ begin
762
+ # Handle both Unix timestamps and date strings
763
+ time = if timestamp.is_a?(Integer) || timestamp.to_s.match?(/^\d+$/)
764
+ Time.at(timestamp.to_i)
765
+ else
766
+ Time.parse(timestamp.to_s)
767
+ end
768
+
769
+ if time.to_date == Date.today
770
+ time.strftime("%H:%M")
771
+ else
772
+ time.strftime("%m/%d")
773
+ end
774
+ rescue
775
+ ""
776
+ end
777
+ end
778
+ end
779
+ end
780
+ end