Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/controllers/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/javascript/controllers/keyboard_nav_controller.js
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
48 changes: 47 additions & 1 deletion app/javascript/controllers/message_list_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,63 @@ 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) {
previousSelected.classList.remove('message-selected')
}

// Add highlight to clicked message
const messageElement = event.currentTarget
if (messageElement) {
messageElement.classList.add('message-selected')
}
Expand Down
79 changes: 78 additions & 1 deletion app/views/messages/_message.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden" id="<%= dom_id message %>">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden" id="<%= dom_id message %>" data-list-seq="<%= message.list_seq %>">
<div class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-6 py-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3"><%= message.subject %></h2>
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
Expand Down Expand Up @@ -32,4 +32,81 @@
</div>
</div>
<% end %>

<% if defined?(@prev_thread) || defined?(@next_thread) || defined?(@prev_message_in_thread) || defined?(@next_message_in_thread) %>
<div class="border-t border-gray-200 dark:border-gray-700 px-6 py-4 bg-gray-50 dark:bg-gray-900" data-controller="keyboard-nav">
<div class="grid grid-cols-2 gap-4">
<div>
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Thread</h3>
<div class="flex gap-2">
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Prev
<% end %>
<% else %>
<span 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-400 dark:text-gray-600 cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Prev
</span>
<% 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
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<% end %>
<% else %>
<span 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-400 dark:text-gray-600 cursor-not-allowed">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<% end %>
</div>
</div>
<div>
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">In This Thread</h3>
<div class="flex gap-2">
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Prev
<% end %>
<% else %>
<span 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-400 dark:text-gray-600 cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Prev
</span>
<% 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
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<% end %>
<% else %>
<span 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-400 dark:text-gray-600 cursor-not-allowed">
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>