diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index f792d4f..60345eb 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -23,6 +23,9 @@ def show(list_name:, list_seq:) @list = List.find_by_name(list_name) @message = Message.find_by!(list_id: @list, list_seq: list_seq) + # Calculate navigation links + calculate_navigation_links + # If this is a turbo frame request, just render the message return if turbo_frame_request? @@ -31,6 +34,31 @@ def show(list_name:, list_seq:) private + def calculate_navigation_links + # Find root of current thread + root = @message + while root.parent_id + root = Message.find(root.parent_id) + end + + # Find previous/next thread (root messages) + @prev_thread = Message.where(list_id: @list, parent_id: nil).where('id < ?', root.id).order(id: :desc).first + @next_thread = Message.where(list_id: @list, parent_id: nil).where('id > ?', root.id).order(:id).first + + # Get all messages in this thread + thread_messages = Message.with_recursive( + thread_msgs: [ + Message.where(id: root.id), + Message.joins('inner join thread_msgs on messages.parent_id = thread_msgs.id') + ] + ).joins('inner join thread_msgs on thread_msgs.id = messages.id').order(:id).to_a + + # Find previous/next message in thread + current_index = thread_messages.index {|m| m.id == @message.id } + @prev_message_in_thread = thread_messages[current_index - 1] if current_index && current_index > 0 + @next_message_in_thread = thread_messages[current_index + 1] if current_index + end + def render_threads(yyyymm: nil) @yyyymms = Message.where(list_id: @list).order('yyyymm').pluck(Arel.sql "distinct to_char(published_at, 'YYYYMM') as yyyymm") @yyyymm = yyyymm || @yyyymms.last diff --git a/app/javascript/controllers/keyboard_nav_controller.js b/app/javascript/controllers/keyboard_nav_controller.js new file mode 100644 index 0000000..8dab981 --- /dev/null +++ b/app/javascript/controllers/keyboard_nav_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.handleKeydown = this.handleKeydown.bind(this) + document.addEventListener('keydown', this.handleKeydown) + } + + disconnect() { + document.removeEventListener('keydown', this.handleKeydown) + } + + handleKeydown(event) { + // Don't trigger if user is typing in an input field + if (event.target.matches('input, textarea, select')) { + return + } + + const key = event.key.toLowerCase() + + // Define key mappings + const keyMappings = { + // Arrow keys + 'arrowup': 'prev-thread', + 'arrowdown': 'next-thread', + 'arrowleft': 'prev-message', + 'arrowright': 'next-message', + // Vim-style (only when Ctrl is not pressed) + 'k': !event.ctrlKey ? 'prev-thread' : null, + 'j': !event.ctrlKey ? 'next-thread' : null, + 'h': !event.ctrlKey ? 'prev-message' : null, + 'l': !event.ctrlKey ? 'next-message' : null, + // Emacs-style (only when Ctrl is pressed) + 'p': event.ctrlKey ? 'prev-thread' : null, + 'n': event.ctrlKey ? 'next-thread' : null, + 'b': event.ctrlKey ? 'prev-message' : null, + 'f': event.ctrlKey ? 'next-message' : null, + } + + const navAction = keyMappings[key] + if (!navAction) return + + const link = this.element.querySelector(`[data-nav="${navAction}"]`) + if (link && !link.classList.contains('cursor-not-allowed')) { + event.preventDefault() + link.click() + } + } +} diff --git a/app/javascript/controllers/message_list_controller.js b/app/javascript/controllers/message_list_controller.js index 0ce0b0f..0c941f1 100644 --- a/app/javascript/controllers/message_list_controller.js +++ b/app/javascript/controllers/message_list_controller.js @@ -9,9 +9,56 @@ export default class extends Controller { selected.scrollIntoView({behavior: 'smooth', block: 'center'}) } }, 100) + + // Listen for turbo frame loads to update selection + document.addEventListener('turbo:frame-load', this.handleFrameLoad.bind(this)) + } + + disconnect() { + document.removeEventListener('turbo:frame-load', this.handleFrameLoad.bind(this)) + } + + handleFrameLoad(event) { + if (event.target.id === 'message_content') { + // Extract list_seq from the loaded message + const messageElement = event.target.querySelector('[data-list-seq]') + if (messageElement) { + const listSeq = messageElement.dataset.listSeq + // Find corresponding link in the left pane by matching the URL ending + const correspondingLink = this.element.querySelector(`a[href$="/${listSeq}"]`) + if (correspondingLink) { + const messageItem = correspondingLink.closest('.message-item') + + // Check if this message is inside a collapsed thread + const threadMessage = correspondingLink.closest('.thread-message') + if (threadMessage) { + const parentThreadMessage = threadMessage.parentElement.closest('[data-controller="thread"]') + if (parentThreadMessage) { + // Find the children container and expand it + const childrenContainer = parentThreadMessage.querySelector('[data-thread-target="children"]') + const icon = parentThreadMessage.querySelector('[data-thread-target="icon"]') + if (childrenContainer && childrenContainer.classList.contains('hidden')) { + childrenContainer.classList.remove('hidden') + if (icon) { + icon.classList.add('rotate-90') + } + } + } + } + + this.selectMessage(messageItem) + // Scroll to the selected message + messageItem.scrollIntoView({behavior: 'smooth', block: 'center'}) + } + } + } } select(event) { + this.selectMessage(event.currentTarget) + } + + selectMessage(messageElement) { // Remove highlight from previously selected message const previousSelected = this.element.querySelector('.message-selected') if (previousSelected) { @@ -19,7 +66,6 @@ export default class extends Controller { } // Add highlight to clicked message - const messageElement = event.currentTarget if (messageElement) { messageElement.classList.add('message-selected') } diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb index be51739..67dc989 100644 --- a/app/views/messages/_message.html.erb +++ b/app/views/messages/_message.html.erb @@ -1,4 +1,4 @@ -
+

<%= message.subject %>

@@ -32,4 +32,81 @@
<% end %> + + <% if defined?(@prev_thread) || defined?(@next_thread) || defined?(@prev_message_in_thread) || defined?(@next_message_in_thread) %> +
+
+
+

Thread

+
+ <% if @prev_thread %> + <%= link_to [message.list, @prev_thread], class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors", data: {turbo_frame: 'message_content', turbo_action: 'advance', nav: 'prev-thread'} do %> + + + + Prev + <% end %> + <% else %> + + + + + Prev + + <% end %> + <% if @next_thread %> + <%= link_to [message.list, @next_thread], class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors", data: {turbo_frame: 'message_content', turbo_action: 'advance', nav: 'next-thread'} do %> + Next + + + + <% end %> + <% else %> + + Next + + + + + <% end %> +
+
+
+

In This Thread

+
+ <% if @prev_message_in_thread %> + <%= link_to [message.list, @prev_message_in_thread], class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors", data: {turbo_frame: 'message_content', turbo_action: 'advance', nav: 'prev-message'} do %> + + + + Prev + <% end %> + <% else %> + + + + + Prev + + <% end %> + <% if @next_message_in_thread %> + <%= link_to [message.list, @next_message_in_thread], class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors", data: {turbo_frame: 'message_content', turbo_action: 'advance', nav: 'next-message'} do %> + Next + + + + <% end %> + <% else %> + + Next + + + + + <% end %> +
+
+
+
+ <% end %>