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.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- 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
|