Skip to content

Commit 9e7d8b4

Browse files
authored
Merge pull request #83 from amatsuda/thread_navigations
Thread navigations
2 parents 8b57d95 + bd5e1ef commit 9e7d8b4

File tree

4 files changed

+202
-2
lines changed

4 files changed

+202
-2
lines changed

app/controllers/messages_controller.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def show(list_name:, list_seq:)
2323
@list = List.find_by_name(list_name)
2424
@message = Message.find_by!(list_id: @list, list_seq: list_seq)
2525

26+
# Calculate navigation links
27+
calculate_navigation_links
28+
2629
# If this is a turbo frame request, just render the message
2730
return if turbo_frame_request?
2831

@@ -31,6 +34,31 @@ def show(list_name:, list_seq:)
3134

3235
private
3336

37+
def calculate_navigation_links
38+
# Find root of current thread
39+
root = @message
40+
while root.parent_id
41+
root = Message.find(root.parent_id)
42+
end
43+
44+
# Find previous/next thread (root messages)
45+
@prev_thread = Message.where(list_id: @list, parent_id: nil).where('id < ?', root.id).order(id: :desc).first
46+
@next_thread = Message.where(list_id: @list, parent_id: nil).where('id > ?', root.id).order(:id).first
47+
48+
# Get all messages in this thread
49+
thread_messages = Message.with_recursive(
50+
thread_msgs: [
51+
Message.where(id: root.id),
52+
Message.joins('inner join thread_msgs on messages.parent_id = thread_msgs.id')
53+
]
54+
).joins('inner join thread_msgs on thread_msgs.id = messages.id').order(:id).to_a
55+
56+
# Find previous/next message in thread
57+
current_index = thread_messages.index {|m| m.id == @message.id }
58+
@prev_message_in_thread = thread_messages[current_index - 1] if current_index && current_index > 0
59+
@next_message_in_thread = thread_messages[current_index + 1] if current_index
60+
end
61+
3462
def render_threads(yyyymm: nil)
3563
@yyyymms = Message.where(list_id: @list).order('yyyymm').pluck(Arel.sql "distinct to_char(published_at, 'YYYYMM') as yyyymm")
3664
@yyyymm = yyyymm || @yyyymms.last
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
connect() {
5+
this.handleKeydown = this.handleKeydown.bind(this)
6+
document.addEventListener('keydown', this.handleKeydown)
7+
}
8+
9+
disconnect() {
10+
document.removeEventListener('keydown', this.handleKeydown)
11+
}
12+
13+
handleKeydown(event) {
14+
// Don't trigger if user is typing in an input field
15+
if (event.target.matches('input, textarea, select')) {
16+
return
17+
}
18+
19+
const key = event.key.toLowerCase()
20+
21+
// Define key mappings
22+
const keyMappings = {
23+
// Arrow keys
24+
'arrowup': 'prev-thread',
25+
'arrowdown': 'next-thread',
26+
'arrowleft': 'prev-message',
27+
'arrowright': 'next-message',
28+
// Vim-style (only when Ctrl is not pressed)
29+
'k': !event.ctrlKey ? 'prev-thread' : null,
30+
'j': !event.ctrlKey ? 'next-thread' : null,
31+
'h': !event.ctrlKey ? 'prev-message' : null,
32+
'l': !event.ctrlKey ? 'next-message' : null,
33+
// Emacs-style (only when Ctrl is pressed)
34+
'p': event.ctrlKey ? 'prev-thread' : null,
35+
'n': event.ctrlKey ? 'next-thread' : null,
36+
'b': event.ctrlKey ? 'prev-message' : null,
37+
'f': event.ctrlKey ? 'next-message' : null,
38+
}
39+
40+
const navAction = keyMappings[key]
41+
if (!navAction) return
42+
43+
const link = this.element.querySelector(`[data-nav="${navAction}"]`)
44+
if (link && !link.classList.contains('cursor-not-allowed')) {
45+
event.preventDefault()
46+
link.click()
47+
}
48+
}
49+
}

app/javascript/controllers/message_list_controller.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,63 @@ export default class extends Controller {
99
selected.scrollIntoView({behavior: 'smooth', block: 'center'})
1010
}
1111
}, 100)
12+
13+
// Listen for turbo frame loads to update selection
14+
document.addEventListener('turbo:frame-load', this.handleFrameLoad.bind(this))
15+
}
16+
17+
disconnect() {
18+
document.removeEventListener('turbo:frame-load', this.handleFrameLoad.bind(this))
19+
}
20+
21+
handleFrameLoad(event) {
22+
if (event.target.id === 'message_content') {
23+
// Extract list_seq from the loaded message
24+
const messageElement = event.target.querySelector('[data-list-seq]')
25+
if (messageElement) {
26+
const listSeq = messageElement.dataset.listSeq
27+
// Find corresponding link in the left pane by matching the URL ending
28+
const correspondingLink = this.element.querySelector(`a[href$="/${listSeq}"]`)
29+
if (correspondingLink) {
30+
const messageItem = correspondingLink.closest('.message-item')
31+
32+
// Check if this message is inside a collapsed thread
33+
const threadMessage = correspondingLink.closest('.thread-message')
34+
if (threadMessage) {
35+
const parentThreadMessage = threadMessage.parentElement.closest('[data-controller="thread"]')
36+
if (parentThreadMessage) {
37+
// Find the children container and expand it
38+
const childrenContainer = parentThreadMessage.querySelector('[data-thread-target="children"]')
39+
const icon = parentThreadMessage.querySelector('[data-thread-target="icon"]')
40+
if (childrenContainer && childrenContainer.classList.contains('hidden')) {
41+
childrenContainer.classList.remove('hidden')
42+
if (icon) {
43+
icon.classList.add('rotate-90')
44+
}
45+
}
46+
}
47+
}
48+
49+
this.selectMessage(messageItem)
50+
// Scroll to the selected message
51+
messageItem.scrollIntoView({behavior: 'smooth', block: 'center'})
52+
}
53+
}
54+
}
1255
}
1356

1457
select(event) {
58+
this.selectMessage(event.currentTarget)
59+
}
60+
61+
selectMessage(messageElement) {
1562
// Remove highlight from previously selected message
1663
const previousSelected = this.element.querySelector('.message-selected')
1764
if (previousSelected) {
1865
previousSelected.classList.remove('message-selected')
1966
}
2067

2168
// Add highlight to clicked message
22-
const messageElement = event.currentTarget
2369
if (messageElement) {
2470
messageElement.classList.add('message-selected')
2571
}

app/views/messages/_message.html.erb

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<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 %>">
1+
<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 %>">
22
<div class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-6 py-4">
33
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3"><%= message.subject %></h2>
44
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
@@ -32,4 +32,81 @@
3232
</div>
3333
</div>
3434
<% end %>
35+
36+
<% if defined?(@prev_thread) || defined?(@next_thread) || defined?(@prev_message_in_thread) || defined?(@next_message_in_thread) %>
37+
<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">
38+
<div class="grid grid-cols-2 gap-4">
39+
<div>
40+
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Thread</h3>
41+
<div class="flex gap-2">
42+
<% if @prev_thread %>
43+
<%= 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 %>
44+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
45+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
46+
</svg>
47+
Prev
48+
<% end %>
49+
<% else %>
50+
<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">
51+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
52+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
53+
</svg>
54+
Prev
55+
</span>
56+
<% end %>
57+
<% if @next_thread %>
58+
<%= 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 %>
59+
Next
60+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
62+
</svg>
63+
<% end %>
64+
<% else %>
65+
<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">
66+
Next
67+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
68+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
69+
</svg>
70+
</span>
71+
<% end %>
72+
</div>
73+
</div>
74+
<div>
75+
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">In This Thread</h3>
76+
<div class="flex gap-2">
77+
<% if @prev_message_in_thread %>
78+
<%= 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 %>
79+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
80+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
81+
</svg>
82+
Prev
83+
<% end %>
84+
<% else %>
85+
<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">
86+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
88+
</svg>
89+
Prev
90+
</span>
91+
<% end %>
92+
<% if @next_message_in_thread %>
93+
<%= 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 %>
94+
Next
95+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
96+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
97+
</svg>
98+
<% end %>
99+
<% else %>
100+
<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">
101+
Next
102+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
103+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
104+
</svg>
105+
</span>
106+
<% end %>
107+
</div>
108+
</div>
109+
</div>
110+
</div>
111+
<% end %>
35112
</div>

0 commit comments

Comments
 (0)