Module: Heathrow::UI::ThreadedView

Included in:
Application
Defined in:
lib/heathrow/ui/threaded_view.rb

Overview

Threaded message view with collapsible threads

Instance Method Summary collapse

Instance Method Details

#apply_thread_mode_key(key) ⇒ Object

Apply a mode from a string key



117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/heathrow/ui/threaded_view.rb', line 117

def apply_thread_mode_key(key)
  case key
  when 'flat'
    @show_threaded = false
    @group_by_folder = false
  when 'folders'
    @show_threaded = true
    @group_by_folder = true
  else # 'threaded'
    @show_threaded = true
    @group_by_folder = false
  end
end

#create_section_header(section, type) ⇒ Object

Create a section header message object



656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
# File 'lib/heathrow/ui/threaded_view.rb', line 656

def create_section_header(section, type)
  # Get the timestamp from the most recent message in this section
  latest_timestamp = if section[:messages] && !section[:messages].empty?
    msg_timestamp = section[:messages].first['timestamp']
    # Validate timestamp - some sources have invalid values like "0"
    if msg_timestamp && !msg_timestamp.to_s.empty? && msg_timestamp.to_s != "0"
      begin
        Time.parse(msg_timestamp.to_s)
        msg_timestamp
      rescue
        Time.now.iso8601
      end
    else
      Time.now.iso8601
    end
  else
    Time.now.iso8601
  end
  
  {
    'id' => "header_#{type}_#{section[:name] || Time.now.to_i}",
    'is_header' => true,
    "is_#{type}_header" => true,
    'channel_id' => section[:name],
    'channel_name' => section[:name],  # Add for toggle_group_read_status
    'thread_id' => section[:subject],
    'subject' => section[:name] || section[:subject],
    'content' => "#{section[:messages].size} messages",
    'is_read' => section[:unread_count] == 0 ? 1 : 0,
    'timestamp' => latest_timestamp,
    'sender' => '',
    'source_type' => section[:messages].first&.dig('source_type') || 'unknown',
    'source_id' => section[:messages].first&.dig('source_id'),  # Add source_id from first message
    'section_messages' => section[:messages]  # Store reference to messages for toggle
  }
end

#current_message_for_navigationObject

Get the current message for navigation (works with both flat and threaded views)



165
166
167
168
169
170
171
# File 'lib/heathrow/ui/threaded_view.rb', line 165

def current_message_for_navigation
  if @show_threaded && !@display_messages.empty?
    @display_messages[@index]
  else
    @filtered_messages[@index]
  end
end

#cycle_view_modeObject

Cycle: flat → threaded → folder-grouped → flat



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/heathrow/ui/threaded_view.rb', line 48

def cycle_view_mode
  if !@show_threaded
    # flat → threaded
    @show_threaded = true
    @group_by_folder = false
    set_feedback("Threaded view", 156, 2)
  elsif @show_threaded && !@group_by_folder
    # threaded → folder-grouped
    @group_by_folder = true
    set_feedback("Folder-grouped view", 156, 2)
  else
    # folder-grouped → flat
    @show_threaded = false
    @group_by_folder = false
    set_feedback("Flat view", 156, 2)
  end
  save_view_thread_mode
  reset_threading
  organize_current_messages(true) if @show_threaded
  render_all
end

#filtered_messages_sizeObject

Get the size of the current message array for navigation



174
175
176
177
178
179
180
# File 'lib/heathrow/ui/threaded_view.rb', line 174

def filtered_messages_size
  if @show_threaded && !@display_messages.empty?
    @display_messages.size
  else
    @filtered_messages.size
  end
end

#finalize_line(msg, selected, prefix_text, subject_text, color, padding = "") ⇒ Object

Shared line formatting: selection arrow, underline on subject only, delete/tag/star indicators



694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
# File 'lib/heathrow/ui/threaded_view.rb', line 694

def finalize_line(msg, selected, prefix_text, subject_text, color, padding = "")
  # Unread flag (like mutt's N)
  nflag = msg['is_read'].to_i == 0 ? "N".fg(226) : " "
  # Replied flag (like mutt's r)
  rflag = msg['replied'].to_i == 1 ? "".fg(45) : " "
  tag_color = theme[:tag] || 14
  star_color = theme[:star] || 226

  ind = if @delete_marked&.include?(msg['id'])
          "D".fg(88)
        elsif @tagged_messages&.include?(msg['id'])
          "".fg(tag_color)
        elsif msg['is_starred'] == 1
          "".fg(star_color)
        elsif msg['attachments'].is_a?(Array) && !msg['attachments'].empty? && !prefix_text.include?("")
          "".fg(208)
        else
          " "
        end

  flags = nflag + rflag + ind
  content = prefix_text + subject_text
  if @delete_marked&.include?(msg['id'])
    if selected
      flags + content.ul.fg(88) + padding
    else
      flags + content.fg(88) + padding
    end
  elsif @tagged_messages&.include?(msg['id'])
    if selected
      lead = content[/\A */]
      rest = content[lead.length..]
      flags + lead.bd.fg(tag_color) + rest.ul.bd.fg(tag_color) + padding
    else
      flags + content.fg(tag_color) + padding
    end
  elsif msg['is_starred'] == 1
    if selected
      lead = content[/\A */]
      rest = content[lead.length..]
      flags + lead.bd.fg(star_color) + rest.ul.bd.fg(star_color) + padding
    else
      flags + content.fg(star_color) + padding
    end
  elsif selected
    lead = content[/\A */]
    rest = content[lead.length..]
    flags + lead.bd.fg(color) + rest.ul.bd.fg(color) + padding
  elsif msg['is_read'].to_i == 0
    flags + content.bd.fg(color) + padding
  else
    flags + content.fg(color) + padding
  end
end

#format_channel_header(section, selected) ⇒ Object

Format channel header line



470
471
472
473
474
475
476
# File 'lib/heathrow/ui/threaded_view.rb', line 470

def format_channel_header(section, selected)
  collapsed = @channel_collapsed[section[:name]]
  display_name = section[:display_name] || section[:name]
  color = get_source_color({'source_type' => section[:source]})
  format_section_header(display_name, collapsed, color, section, selected,
                        source_icon: get_source_icon(section[:source]))
end

#format_channel_message(msg, selected, indent = "", source_type = nil) ⇒ Object

Format a channel message line



517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/heathrow/ui/threaded_view.rb', line 517

def format_channel_message(msg, selected, indent = "", source_type = nil)
  sender = display_sender(msg)
  
  # For RSS, show article title not sender (use passed source_type since DB lacks it)
  if (source_type || msg['source_type']) == 'rss'
    sender = ''  # Don't show sender for RSS
  else
    sender = truncate_to_width(sender, 14) + '' if Rcurses.display_width(sender) > 15
  end
  
  # For Discord/Slack channels, show content not channel name
  content = msg['content'] || ''
  
  # Check if subject is a channel name or ID
  if msg['subject'] =~ /^\d+$/ || msg['subject'] =~ /#/
    # It's a channel indicator, show content instead
    display_content = content.gsub(/\n/, ' ')
  elsif msg['subject'] == msg['recipient']
    # Subject is same as recipient (channel name), show content
    display_content = content.gsub(/\n/, ' ')
  else
    # Normal subject, show it
    display_content = msg['subject'] || content
    display_content = display_content.gsub(/\n/, ' ')
  end
  
  unread = msg['is_read'].to_i == 0 ? "" : " "
  color = get_source_color(msg)

  if sender.empty?
    prefix = "#{unread} "
  else
    prefix = "#{unread} #{sender + ' ' * [15 - Rcurses.display_width(sender), 0].max} "
  end

  # Truncate content to fit single line (prefix + 2 for nflag+ind from finalize_line)
  pane_width = @panes[:left].w - 5
  available = pane_width - Rcurses.display_width(prefix)
  if display_content && Rcurses.display_width(display_content) > available && available > 0
    display_content = truncate_to_width(display_content, available - 1) + ""
  end

  finalize_line(msg, selected, prefix, display_content, color)
end

#format_dm_header(section, selected, collapsed: @dm_section_collapsed) ⇒ Object

Format DM section header



479
480
481
482
# File 'lib/heathrow/ui/threaded_view.rb', line 479

def format_dm_header(section, selected, collapsed: @dm_section_collapsed)
  dm_text = section[:name] || "Direct Messages"
  format_section_header(dm_text, collapsed, theme[:dm], section, selected, source_icon: "")
end

#format_dm_message(msg, selected) ⇒ Object

Format DM message



611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
# File 'lib/heathrow/ui/threaded_view.rb', line 611

def format_dm_message(msg, selected)
  sender = display_sender(msg)
  platform = get_source_icon(msg['source_type'])

  # Truncate sender to fixed width
  sender_max = 15
  sdw = Rcurses.display_width(sender)
  sender = sdw > sender_max ? truncate_to_width(sender, sender_max - 1) + '' : sender + ' ' * [sender_max - sdw, 0].max

  content = msg['content'] || ''
  content = content.gsub(/\n/, ' ')

  unread = msg['is_read'].to_i == 0 ? "" : " "
  prefix = "#{unread} #{platform} #{sender} "

  # Truncate content to fit single line
  pane_width = @panes[:left].w - 5
  available = pane_width - Rcurses.display_width(prefix)
  if Rcurses.display_width(content) > available && available > 0
    content = truncate_to_width(content, available - 1) + ""
  end

  color = get_source_color(msg)
  finalize_line(msg, selected, prefix, content, color)
end

#format_section_header(display_name, collapsed, color, section, selected, source_icon: nil) ⇒ Object

Shared section header formatter



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/heathrow/ui/threaded_view.rb', line 443

def format_section_header(display_name, collapsed, color, section, selected, source_icon: nil)
  icon = collapsed ? "" : ""
  unread = section[:unread_count].to_i > 0 ? " (#{section[:unread_count]})" : ""
  dates = (section[:messages])
  date_suffix = dates.empty? ? "" : " #{dates}"

  pane_width = @panes[:left].w - 5
  prefix = source_icon ? "#{icon} #{source_icon} " : "#{icon} "
  used_space = prefix.length + unread.length + date_suffix.length
  available = pane_width - used_space

  if display_name.length > available && available > 3
    display_name = display_name[0..(available-2)] + ""
  end

  name_part = "#{display_name}#{unread}"

  if selected
    prefix.bd.fg(color) + name_part.ul.bd.fg(color) + date_suffix.fg(245)
  elsif section[:unread_count].to_i > 0
    prefix.bd.fg(color) + name_part.bd.fg(color) + date_suffix.fg(245)
  else
    (prefix + name_part).fg(color) + date_suffix.fg(245)
  end
end

#format_thread_header(section, selected) ⇒ Object

Format thread header



485
486
487
488
489
# File 'lib/heathrow/ui/threaded_view.rb', line 485

def format_thread_header(section, selected)
  collapsed = @thread_collapsed[section[:subject]]
  subject = section[:subject] || "(no subject)"
  format_section_header(subject, collapsed, theme[:thread], section, selected)
end

#format_thread_message(msg, selected, indent) ⇒ Object

Format a thread message



583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# File 'lib/heathrow/ui/threaded_view.rb', line 583

def format_thread_message(msg, selected, indent)
  sender = display_sender(msg)
  pane_width = @panes[:left].w - 5
  icon = get_source_icon(msg['source_type'])

  timestamp = (parse_timestamp(msg['timestamp']) || "").ljust(6)
  sender_max = 15
  sdw = Rcurses.display_width(sender)
  sender = sdw > sender_max ? truncate_to_width(sender, sender_max - 1) + '' : sender + ' ' * [sender_max - sdw, 0].max
  child_indent = indent.empty? ? "" : "    "
  prefix = "#{child_indent}#{timestamp} #{icon} #{sender} "

  content = msg['subject'] || msg['content'] || ''
  content = content.dup if content.frozen?
  content = content.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') rescue content.force_encoding('UTF-8').scrub('?')
  content = content.gsub(/\n/, ' ')

  used_space = Rcurses.display_width(prefix) + 1
  available = pane_width - used_space
  if Rcurses.display_width(content) > available && available > 0
    content = truncate_to_width(content, available - 1) + ""
  end

  color = get_source_color(msg)
  finalize_line(msg, selected, prefix, content, color)
end

#format_thread_reply(msg, selected, indent) ⇒ Object

Format a thread reply



563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'lib/heathrow/ui/threaded_view.rb', line 563

def format_thread_reply(msg, selected, indent)
  sender = display_sender(msg)
  sender = truncate_to_width(sender, 12) if Rcurses.display_width(sender) > 12

  content = msg['content'] || ''
  content = content.gsub(/\n/, ' ')

  # Truncate based on pane width
  pane_width = @panes[:left].w - 5
  used_space = 2 + Rcurses.display_width(indent) + 3 + Rcurses.display_width(sender) + 2
  available = pane_width - used_space
  if Rcurses.display_width(content) > available && available > 0
    content = truncate_to_width(content, available - 1) + ""
  end
  
  prefix = "#{indent}└─ #{sender}: "
  finalize_line(msg, selected, prefix, content, 245)
end

#format_time(timestamp) ⇒ Object

Format timestamp



750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
# File 'lib/heathrow/ui/threaded_view.rb', line 750

def format_time(timestamp)
  return "" unless timestamp

  begin
    # Handle both Unix timestamps and date strings
    time = if timestamp.is_a?(Integer) || timestamp.to_s.match?(/^\d+$/)
      Time.at(timestamp.to_i)
    else
      Time.parse(timestamp.to_s)
    end

    if time.to_date == Date.today
      time.strftime("%H:%M")
    else
      time.strftime("%m/%d")
    end
  rescue
    ""
  end
end

#get_source_icon(source) ⇒ Object

Get source platform icon



638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/heathrow/ui/threaded_view.rb', line 638

def get_source_icon(source)
  case source.to_s
  when 'discord' then ''  # Diamond for Discord (no emoji)
  when 'slack' then '#'     # Hash remains for Slack
  when 'telegram' then ''  # Airplane works fine
  when 'whatsapp' then ''  # Filled circle works fine
  when 'reddit' then '®'    # Registered mark for Reddit
  when 'email', 'gmail', 'imap', 'maildir' then ''  # Letter symbol for email
  when 'rss' then ''     # Diamond for RSS
  when 'web' then ''     # Target for web watch
  when 'messenger' then ''  # Filled circle for Messenger
  when 'instagram' then ''  # Diamond for Instagram
  when 'weechat' then ''   # WeeChat relay
  else ''
  end
end

#initialize_threadingObject

Thread state tracking



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# File 'lib/heathrow/ui/threaded_view.rb', line 9

def initialize_threading
  @thread_collapsed = {}  # Track which threads are collapsed
  @channel_collapsed = {}  # Track which channels are collapsed
  @dm_section_collapsed = true  # DM section state - START COLLAPSED (single section mode)
  @dm_collapsed = {}  # Per-conversation DM collapse tracking
  @show_threaded = true  # Toggle between flat and threaded view
  @group_by_folder = false  # Toggle between threaded and folder grouping
  @thread_indent = "  "  # Indentation for thread replies
  @organizer = nil
  @base_messages = nil  # Base messages from database (never changes during threading)
  @display_messages = []  # Messages currently displayed (including headers)
  @threading_initialized = false  # Track if we've started threading
  @all_start_collapsed = true  # Start with everything collapsed
  @section_order = nil  # Custom section order (array of names)
  @view_thread_modes = {}  # Per-view threading mode: key => {threaded:, folder:}
  @organized_cache = nil
  @organized_cache_key = nil
end

#move_section(direction) ⇒ Object

Move current section up or down in the list



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/heathrow/ui/threaded_view.rb', line 231

def move_section(direction)
  return unless @show_threaded && @organizer

  msg = current_message_for_navigation
  return unless msg
  # Allow moving channel headers and thread headers
  return unless msg['is_channel_header'] || msg['is_thread_header']

  section_name = msg['channel_id'] || msg['thread_id']
  return unless section_name

  # Build section order from current organized view if not set
  organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
  @section_order ||= organized.map { |s| s[:name] || s[:subject] }

  idx = @section_order.index(section_name)
  return unless idx

  new_idx = idx + direction
  return if new_idx < 0 || new_idx >= @section_order.size

  # Swap
  @section_order[idx], @section_order[new_idx] = @section_order[new_idx], @section_order[idx]

  # Persist to view config
  save_section_order

  # Re-render and find new cursor position
  render_message_list_threaded
  key = msg['is_channel_header'] ? 'channel_id' : 'thread_id'
  new_display_idx = @display_messages.index { |m| m[key] == section_name }
  @index = new_display_idx if new_display_idx
  render_message_list_threaded
end

#organize_current_messages(force_reinit = false) ⇒ Object

Organize messages for current view



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/heathrow/ui/threaded_view.rb', line 183

def organize_current_messages(force_reinit = false)
  return unless @show_threaded

  # Only organize once per message set - capture the base messages on first run
  # OR if we're forcing reinitialization after a sort change
  if !@threading_initialized || force_reinit
    @base_messages = @filtered_messages.dup
    @threading_initialized = true
    @organized_cache = nil
    @organized_cache_key = nil

    require_relative '../message_organizer'
    @organizer = MessageOrganizer.new(@base_messages, @db, group_by_folder: @group_by_folder)
  end
end

#render_channel_messages(messages, lines, visible_messages, start_index, section_source = nil) ⇒ Object

Format channel message



492
493
494
495
496
497
498
499
500
501
502
503
# File 'lib/heathrow/ui/threaded_view.rb', line 492

def render_channel_messages(messages, lines, visible_messages, start_index, section_source = nil)
  messages.each_with_index do |msg, i|
    # Use section source (from organizer) since DB messages don't have source_type
    source_type = section_source || msg['source_type'] || 'unknown'
    lines << format_channel_message(msg, start_index + i == @index, "", source_type)
    
    # Don't duplicate the message - just add channel_id directly
    # This ensures updates to is_read status are preserved
    msg['channel_id'] ||= @current_channel_id
    visible_messages << msg
  end
end

#render_message_list_threadedObject

Render threaded message list



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/heathrow/ui/threaded_view.rb', line 286

def render_message_list_threaded
  return render_message_list unless @show_threaded && @organizer
  
  lines = []
  visible_messages = []
  current_index = 0
  @scroll_offset ||= 0  # Track scroll position

  cache_key = "#{@sort_order}|#{@sort_inverted}|#{@filtered_messages.size}"
  if @organized_cache && @organized_cache_key == cache_key
    organized = @organized_cache
  else
    organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
    @organized_cache = organized
    @organized_cache_key = cache_key
  end

  # Apply custom section order if set
  if @section_order && !@section_order.empty?
    order_map = {}
    @section_order.each_with_index { |name, i| order_map[name] = i }
    organized.sort_by! { |s| order_map[s[:name] || s[:subject]] || 9999 }
  end

  organized.each do |section|
    case section[:type]
    when 'channel'
      # Channel header
      header_msg = create_section_header(section, 'channel')
      
      # Initialize collapse state if not set
      if !@channel_collapsed.key?(section[:name])
        @channel_collapsed[section[:name]] = @all_start_collapsed
      end
      
      # Store line index for scrolling
      lines << format_channel_header(section, current_index == @index)
      visible_messages << header_msg
      current_index += 1
      
      # Channel messages if not collapsed
      unless @channel_collapsed[section[:name]]
        # Set current channel ID for messages
        @current_channel_id = section[:name]
        render_channel_messages(section[:messages], lines, visible_messages, current_index, section[:source])
        current_index += section[:messages].size
      end
      
    when 'dm_section'
      # DM section header
      dm_key = section[:name]
      # Use per-conversation collapse if in conversation sort, otherwise single toggle
      collapsed = if @sort_order == 'conversation'
                    @dm_collapsed.fetch(dm_key, true)  # Default collapsed
                  else
                    @dm_section_collapsed
                  end
      header_msg = create_section_header(section, 'dm')
      lines << format_dm_header(section, current_index == @index, collapsed: collapsed)
      visible_messages << header_msg
      current_index += 1

      # DMs if not collapsed
      unless collapsed
        section[:messages].each do |msg|
          lines << format_dm_message(msg, current_index == @index)
          visible_messages << msg
          current_index += 1
        end
      end
      
    when 'thread'
      if section[:messages] && section[:messages].size == 1
        # Single message — show as flat item, no header
        msg = section[:messages].first
        lines << format_thread_message(msg, current_index == @index, "")
        visible_messages << msg
        current_index += 1
      else
        # Multi-message thread — collapsible header
        header_msg = create_section_header(section, 'thread')

        if !@thread_collapsed.key?(section[:subject])
          @thread_collapsed[section[:subject]] = @all_start_collapsed
        end

        lines << format_thread_header(section, current_index == @index)
        visible_messages << header_msg
        current_index += 1

        unless @thread_collapsed[section[:subject]]
          render_thread_messages(section[:messages], lines, visible_messages, current_index)
          current_index += section[:messages].size
        end
      end
    end
  end
  
  # Store display messages for navigation (don't overwrite @filtered_messages)
  @display_messages = visible_messages

  # Restore index by message ID after a background refresh rebuilt the list.
  # @pending_restore_id is set by the pending_view_refresh handler because
  # @display_messages is empty at that point (reset_threading clears it).
  if @pending_restore_id
    new_idx = @display_messages.index { |m| m['id'] == @pending_restore_id }
    @pending_restore_id = nil
    if new_idx && new_idx != @index
      @index = new_idx
      return render_message_list_threaded  # Re-render with correct highlight
    end
  end

  # Give full text to rcurses and use its scrolling with markers
  @panes[:left].scroll = true
  new_text = lines.join("\n")

  # Calculate scroll position to keep current item visible
  pane_height = @panes[:left].h - 2
  scrolloff = 3
  old_ix = @panes[:left].ix

  if @index < @panes[:left].ix + scrolloff
    @panes[:left].ix = [@index - scrolloff, 0].max
  elsif @index > @panes[:left].ix + pane_height - scrolloff - 1
    max_ix = [lines.size - pane_height, 0].max
    @panes[:left].ix = [@index - pane_height + scrolloff + 1, max_ix].min
  end

  @panes[:left].text = new_text
  @panes[:left].full_refresh
end

#render_thread_messages(messages, lines, visible_messages, start_index) ⇒ Object

Format thread messages



506
507
508
509
510
511
512
513
514
# File 'lib/heathrow/ui/threaded_view.rb', line 506

def render_thread_messages(messages, lines, visible_messages, start_index)
  messages.each_with_index do |msg, i|
    level = (msg['thread_level'] || 0) + 1  # +1 to indent under thread header
    indent = @thread_indent * level

    lines << format_thread_message(msg, start_index + i == @index, indent)
    visible_messages << msg
  end
end

#reset_threading(preserve_collapsed_state = false) ⇒ Object

Reset threading state when messages change



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/heathrow/ui/threaded_view.rb', line 143

def reset_threading(preserve_collapsed_state = false)
  @base_messages = nil
  @organizer = nil
  @threading_initialized = false
  @display_messages = []
  @scroll_offset = 0  # Reset scroll position
  @organized_cache = nil
  @organized_cache_key = nil

  # Preserve or reset collapsed states
  if !preserve_collapsed_state
    # Reset collapsed states to start collapsed
    @thread_collapsed = {}
    @channel_collapsed = {}
    @dm_section_collapsed = true
    @dm_collapsed = {}
    @section_order = nil
  end
  # If preserve_collapsed_state is true, keep existing collapsed states
end

#restore_view_thread_modeObject

Restore threading mode for the active view



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/heathrow/ui/threaded_view.rb', line 81

def restore_view_thread_mode
  return false unless @current_view

  # Check in-memory cache first
  if @view_thread_modes.key?(@current_view)
    mode = @view_thread_modes[@current_view]
    @show_threaded = mode[:threaded]
    @group_by_folder = mode[:folder]
    return true
  end

  # Load from settings table
  row = @db.db.get_first_row(
    "SELECT value FROM settings WHERE key = ?",
    ["thread_mode_#{@current_view}"]
  )
  mode_key = row && row['value']
  return false unless mode_key

  apply_thread_mode_key(mode_key)
  @view_thread_modes[@current_view] = { threaded: @show_threaded, folder: @group_by_folder }
  true
end

#save_section_orderObject

Save section order into the current view's filters in the database



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/heathrow/ui/threaded_view.rb', line 267

def save_section_order
  return unless @current_view && @views[@current_view] && @section_order
  view = @views[@current_view]
  view[:filters] ||= {}
  view[:filters]['section_order'] = @section_order

  # Persist to database
  if view[:id]
    @db.save_view({
      id: view[:id],
      name: view[:name],
      key_binding: @current_view,
      filters: view[:filters],
      sort_order: view[:sort_order] || 'timestamp DESC'
    })
  end
end

#save_view_thread_modeObject

Save current threading mode for the active view (persistent)



71
72
73
74
75
76
77
78
# File 'lib/heathrow/ui/threaded_view.rb', line 71

def save_view_thread_mode
  return unless @current_view
  @view_thread_modes[@current_view] = { threaded: @show_threaded, folder: @group_by_folder }
  @db.execute(
    "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
    ["thread_mode_#{@current_view}", thread_mode_key, Time.now.to_i]
  )
end

#section_date_range(messages) ⇒ Object

Get date range string for a section's messages



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/heathrow/ui/threaded_view.rb', line 420

def (messages)
  return "" if messages.nil? || messages.empty?
  timestamps = messages.map { |m| m['timestamp'] }.compact
  return "" if timestamps.empty?
  times = timestamps.map { |t|
    if t.is_a?(Integer) || t.to_s.match?(/^\d+$/)
      Time.at(t.to_i)
    else
      Time.parse(t.to_s) rescue nil
    end
  }.compact
  return "" if times.empty?
  oldest = times.min
  newest = times.max
  fmt = @date_format || '%b %-d'
  if oldest.to_date == newest.to_date
    newest.strftime(fmt)
  else
    "#{oldest.strftime(fmt)}#{newest.strftime(fmt)}"
  end
end

#thread_mode_keyObject

Convert current mode to a string key



106
107
108
109
110
111
112
113
114
# File 'lib/heathrow/ui/threaded_view.rb', line 106

def thread_mode_key
  if !@show_threaded
    'flat'
  elsif @group_by_folder
    'folders'
  else
    'threaded'
  end
end

#thread_mode_labelObject

Short label for current threading mode



132
133
134
135
136
137
138
139
140
# File 'lib/heathrow/ui/threaded_view.rb', line 132

def thread_mode_label
  if !@show_threaded
    "Flat"
  elsif @group_by_folder
    "Folders"
  else
    "Threaded"
  end
end

#toggle_collapseObject

Toggle collapse state of current item



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/heathrow/ui/threaded_view.rb', line 200

def toggle_collapse
  return unless @show_threaded && @organizer
  
  msg = current_message_for_navigation
  return unless msg
  
  # Determine what type of item this is
  if msg['is_channel_header']
    # Toggle channel collapse
    @channel_collapsed[msg['channel_id']] = !@channel_collapsed[msg['channel_id']]
  elsif msg['is_thread_header']
    # Toggle thread collapse
    @thread_collapsed[msg['thread_id']] = !@thread_collapsed[msg['thread_id']]
  elsif msg['is_dm_header']
    # Toggle DM section (per-conversation or single)
    dm_key = msg['channel_id']
    if @dm_collapsed.key?(dm_key) || @sort_order == 'conversation'
      @dm_collapsed[dm_key] = !@dm_collapsed.fetch(dm_key, true)
    else
      @dm_section_collapsed = !@dm_section_collapsed
    end
  elsif msg['thread_id']
    # Toggle the thread this message belongs to
    @thread_collapsed[msg['thread_id']] = !@thread_collapsed[msg['thread_id']]
  end
  
  # Re-render
  render_message_list_threaded
end

#toggle_folder_viewObject

Toggle folder grouping view



37
38
39
40
41
42
43
44
45
# File 'lib/heathrow/ui/threaded_view.rb', line 37

def toggle_folder_view
  @group_by_folder = !@group_by_folder
  if @group_by_folder
    @show_threaded = true  # Enable threaded view for folder grouping to work
  end
  reset_threading  # Force re-organization
  organize_current_messages(force_reinit: true)
  render_all
end

#toggle_thread_viewObject

Toggle between flat and threaded view



29
30
31
32
33
34
# File 'lib/heathrow/ui/threaded_view.rb', line 29

def toggle_thread_view
  @show_threaded = !@show_threaded
  @group_by_folder = false  # Disable folder grouping when switching to threaded
  organize_current_messages
  render_all
end