Skip to content

Commit 0d87411

Browse files
committed
Lazy loading: load messages in batches
Instead of rendering all messages with the initial page, we only render skeletons and eager load the messages in batches of 50
1 parent 2bb03e2 commit 0d87411

12 files changed

Lines changed: 261 additions & 32 deletions

File tree

app/assets/stylesheets/application.postcss.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
@import "components/avatars.css";
1616
@import "components/participants.css";
1717
@import "components/messages.css";
18+
@import "components/message-skeleton.css";
1819
@import "components/notes.css";
1920
@import "components/activities.css";
2021
@import "components/profile.css";
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.message-batch-skeleton {
2+
padding: var(--spacing-5);
3+
}
4+
5+
.skeleton-line {
6+
height: 1em;
7+
background: var(--color-bg-hover);
8+
border-radius: var(--border-radius-sm);
9+
margin-bottom: var(--spacing-3);
10+
animation: skeleton-pulse 1.5s ease-in-out infinite;
11+
}
12+
13+
.skeleton-line:last-child {
14+
margin-bottom: 0;
15+
}
16+
17+
.skeleton-line-short {
18+
width: 40%;
19+
}
20+
21+
.skeleton-line-medium {
22+
width: 70%;
23+
}
24+
25+
.skeleton-line-long {
26+
width: 90%;
27+
}
28+
29+
@keyframes skeleton-pulse {
30+
0%, 100% { opacity: 0.4; }
31+
50% { opacity: 0.7; }
32+
}

app/controllers/topics_controller.rb

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class TopicsController < ApplicationController
2-
before_action :set_topic, only: [ :show, :aware, :read_all, :star, :unstar, :latest_patchset ]
3-
before_action :require_authentication, only: [ :aware, :aware_bulk, :aware_all, :read_all, :star, :unstar ]
2+
before_action :set_topic, only: [ :show, :message_batch, :aware, :read_all, :unread_all, :star, :unstar, :latest_patchset ]
3+
before_action :require_authentication, only: [ :aware, :aware_bulk, :aware_all, :read_all, :unread_all, :star, :unstar ]
44

55
def index
66
@search_query = nil
@@ -51,25 +51,22 @@ def new_topics_count
5151
end
5252

5353
def show
54+
message_columns = (Message.column_names - %w[body body_tsv]).map { |c| "messages.#{c}" }
5455
messages_scope = @topic.messages
55-
.eager_load(
56+
.select(message_columns)
57+
.preload(
5658
:sender,
5759
:sender_person,
5860
{ sender_person: :default_alias },
59-
{
60-
reply_to: [
61-
:sender,
62-
:sender_person,
63-
{ sender_person: :default_alias }
64-
]
65-
}
61+
:attachments
6662
)
67-
.preload(:attachments)
6863

6964
@messages = messages_scope.order(created_at: :asc)
7065
@message_numbers = @messages.each_with_index.to_h { |msg, idx| [ msg.id, idx + 1 ] }
66+
wire_reply_to_from_loaded_messages!
7167
preload_read_state!
7268
auto_mark_aware!
69+
load_inline_message_bodies!
7370

7471
build_participants_sidebar_data(messages_scope)
7572
build_thread_outline(@messages)
@@ -95,6 +92,20 @@ def show
9592
end
9693
end
9794

95+
def message_batch
96+
ids = params[:ids].to_s.split(",").map(&:to_i).reject(&:zero?).first(50)
97+
return head :bad_request if ids.empty?
98+
99+
@messages = @topic.messages
100+
.where(id: ids)
101+
.eager_load(:sender, :sender_person, { sender_person: :default_alias })
102+
.preload(:attachments)
103+
.order(:created_at)
104+
105+
expires_in 1.year, public: true
106+
render layout: false
107+
end
108+
98109
def aware
99110
last_id = params[:up_to_message_id].presence&.to_i || @topic.messages.maximum(:id)
100111
ThreadAwareness.mark_until(user: current_user, topic: @topic, until_message_id: last_id) if last_id
@@ -140,6 +151,15 @@ def read_all
140151
end
141152
end
142153

154+
def unread_all
155+
MessageReadRange.where(user: current_user, topic: @topic).delete_all
156+
157+
respond_to do |format|
158+
format.json { render json: { status: "ok" } }
159+
format.html { redirect_to topic_path(@topic) }
160+
end
161+
end
162+
143163
def star
144164
TopicStar.create!(user: current_user, topic: @topic)
145165
respond_to do |format|
@@ -413,6 +433,39 @@ def build_thread_outline(messages)
413433
end
414434
end
415435

436+
def wire_reply_to_from_loaded_messages!
437+
messages_by_id = @messages.index_by(&:id)
438+
@messages.each do |msg|
439+
target = msg.reply_to_id ? messages_by_id[msg.reply_to_id] : nil
440+
msg.association(:reply_to).target = target
441+
end
442+
end
443+
444+
def load_inline_message_bodies!
445+
inline_ids = compute_inline_message_ids
446+
return if inline_ids.empty?
447+
448+
full_messages = Message
449+
.where(id: inline_ids)
450+
.eager_load(:sender, :sender_person, { sender_person: :default_alias },
451+
{ reply_to: [ :sender, :sender_person, { sender_person: :default_alias } ] })
452+
.preload(:attachments)
453+
.index_by(&:id)
454+
455+
@messages = @messages.map { |msg| full_messages[msg.id] || msg }
456+
end
457+
458+
def compute_inline_message_ids
459+
ids = []
460+
@messages.each do |msg|
461+
is_read = user_signed_in? && @read_message_ids&.dig(msg.id)
462+
next if is_read
463+
ids << msg.id
464+
break if ids.size >= 20
465+
end
466+
ids
467+
end
468+
416469
def preload_read_state!
417470
return unless user_signed_in?
418471

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static values = {
5+
url: String,
6+
batchSize: { type: Number, default: 50 },
7+
signedIn: { type: Boolean, default: false }
8+
}
9+
10+
static targets = ["skeleton"]
11+
12+
connect() {
13+
this.loadAllBatches()
14+
}
15+
16+
async loadAllBatches() {
17+
const skeletons = this.skeletonTargets
18+
if (skeletons.length === 0) return
19+
20+
const allIds = skeletons.map(el => {
21+
return el.closest(".message-card").dataset.messageId
22+
})
23+
24+
for (let i = 0; i < allIds.length; i += this.batchSizeValue) {
25+
const batchIds = allIds.slice(i, i + this.batchSizeValue)
26+
await this.fetchAndInjectBatch(batchIds)
27+
}
28+
}
29+
30+
async fetchAndInjectBatch(ids) {
31+
const url = `${this.urlValue}?ids=${ids.join(",")}`
32+
try {
33+
const response = await fetch(url)
34+
if (!response.ok) return
35+
const html = await response.text()
36+
this.injectBatch(html)
37+
} catch (e) {
38+
console.warn("batch load failed", e)
39+
}
40+
}
41+
42+
injectBatch(html) {
43+
const template = document.createElement("template")
44+
template.innerHTML = html
45+
46+
template.content.querySelectorAll("[data-message-id]").forEach(batchItem => {
47+
const messageId = batchItem.dataset.messageId
48+
const card = this.element.querySelector(
49+
`.message-card[data-message-id="${messageId}"]`
50+
)
51+
if (!card) return
52+
53+
const skeleton = card.querySelector(".message-batch-skeleton")
54+
if (!skeleton) return // already rendered inline, skip
55+
56+
const isRead = card.dataset.read === "true"
57+
58+
// The batchItem contains a div[data-message-id] wrapping .message-content
59+
// Extract the .message-content element
60+
const content = batchItem.firstElementChild
61+
if (!content) return
62+
63+
if (isRead) {
64+
content.classList.add("is-read")
65+
}
66+
67+
skeleton.replaceWith(content)
68+
69+
if (this.signedInValue) {
70+
const messageContent = card.querySelector(".message-content")
71+
if (messageContent) {
72+
messageContent.dataset.controller = "read-status"
73+
messageContent.dataset.readStatusMessageIdValue = messageId
74+
messageContent.dataset.readStatusReadUrlValue = `/messages/${messageId}/read.json`
75+
messageContent.dataset.readStatusDelaySecondsValue = "5"
76+
}
77+
}
78+
})
79+
}
80+
}

app/javascript/controllers/read_status_controller.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export default class extends Controller {
88
}
99

1010
connect() {
11+
if (this.element.classList.contains("is-read")) {
12+
this.marked = true
13+
return
14+
}
1115
this.marked = false
1216
this.timer = null
1317
this.pollInterval = null

app/javascript/controllers/thread_actions_controller.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default class extends Controller {
44
static values = {
55
topicId: Number,
66
readAllUrl: String,
7+
unreadAllUrl: String,
78
}
89

910
markAllRead(event) {
@@ -18,6 +19,39 @@ export default class extends Controller {
1819
this.post(this.readAllUrlValue)
1920
}
2021

22+
markAllUnread(event) {
23+
event.preventDefault()
24+
document.querySelectorAll(".message-content").forEach(el => el.classList.remove("is-read"))
25+
document.querySelectorAll(".message-card").forEach(card => {
26+
card.dataset.read = "false"
27+
const controller = this.application.getControllerForElementAndIdentifier(card, "message-collapse")
28+
if (controller) {
29+
controller.collapsedValue = false
30+
}
31+
})
32+
this.post(this.unreadAllUrlValue)
33+
}
34+
35+
collapseAll(event) {
36+
event.preventDefault()
37+
document.querySelectorAll(".message-card").forEach(card => {
38+
const controller = this.application.getControllerForElementAndIdentifier(card, "message-collapse")
39+
if (controller) {
40+
controller.collapsedValue = true
41+
}
42+
})
43+
}
44+
45+
expandAll(event) {
46+
event.preventDefault()
47+
document.querySelectorAll(".message-card").forEach(card => {
48+
const controller = this.application.getControllerForElementAndIdentifier(card, "message-collapse")
49+
if (controller) {
50+
controller.collapsedValue = false
51+
}
52+
})
53+
}
54+
2155
post(url, onSuccess) {
2256
fetch(url, {
2357
method: "POST",

app/views/topics/_message.html.slim

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
- message_classes = [("reply-message" if message.reply_to_id), ("message-branch-#{local_assigns[:branch_index]}" unless local_assigns[:branch_index].nil?)].compact.join(" ")
22
- render_mode = local_assigns.fetch(:render_mode, :inline)
3-
- is_collapsed = render_mode != :inline
4-
.message-card id=message_dom_id(message) class=message_classes data-controller="message-collapse" data-message-collapse-collapsed-value=is_collapsed
3+
- is_read = local_assigns.fetch(:is_read, false)
4+
- is_collapsed = (render_mode == :collapsed) || (render_mode == :shell && is_read)
5+
.message-card id=message_dom_id(message) class=message_classes data-controller="message-collapse" data-message-collapse-collapsed-value=is_collapsed data-message-id=message.id data-read=(is_read ? "true" : "false")
56
- if (mid_anchor = message_id_anchor(message))
67
a.message-id-anchor id=mid_anchor aria-hidden="true"
78
- if local_assigns[:is_first_unread]
@@ -56,3 +57,12 @@
5657
= render "topics/message_body", message: message
5758
- elsif render_mode == :collapsed
5859
= turbo_frame_tag "message-body-#{message.id}", src: message_content_path(message)
60+
- elsif render_mode == :shell
61+
.message-batch-skeleton data-batch-loader-target="skeleton"
62+
.skeleton-line.skeleton-line-long
63+
.skeleton-line.skeleton-line-medium
64+
.skeleton-line.skeleton-line-long
65+
.skeleton-line.skeleton-line-short
66+
- if user_signed_in?
67+
- notes_for_message = @notes_by_message&.[](message.id) || []
68+
= render "notes/note_stack", topic: @topic, message: message, notes: notes_for_message

app/views/topics/_message_body.html.slim

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
- is_read = defined?(@read_message_ids) && @read_message_ids[message.id]
2-
- if user_signed_in?
1+
- include_read_status = local_assigns.fetch(:include_read_status, true)
2+
- is_read = include_read_status && defined?(@read_message_ids) && @read_message_ids[message.id]
3+
- if include_read_status && user_signed_in?
34
- read_data = { controller: "read-status", "read-status-message-id-value": message.id, "read-status-read-url-value": read_message_path(message, format: :json), "read-status-delay-seconds-value": read_visibility_seconds }
45
- read_classes = ["message-content", ("is-read" if is_read)].compact
56
- else
@@ -42,7 +43,3 @@
4243
details
4344
summary Import Notes
4445
pre.import-log = message.import_log
45-
46-
- notes_for_message = @notes_by_message&.[](message.id) || []
47-
- if user_signed_in?
48-
= render "notes/note_stack", topic: @topic, message: message, notes: notes_for_message
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- @messages.each do |message|
2+
div data-message-id=message.id
3+
= render "topics/message_body", message: message, include_read_status: false

app/views/topics/show.html.slim

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,26 +197,36 @@
197197
.thread-notes-container
198198
= render "notes/note_stack", topic: @topic, message: nil, notes: thread_notes
199199

200-
.messages-container.flat.asc
200+
.messages-container.flat.asc data-controller="batch-loader" data-batch-loader-url-value=message_batch_topic_path(@topic) data-batch-loader-signed-in-value=(user_signed_in? ? true : false)
201201
- last_message = @messages.last
202202
- if last_message.present?
203-
.thread-actions data-controller="thread-actions" data-thread-actions-topic-id-value=@topic.id data-thread-actions-read-all-url-value=read_all_topic_path(@topic, format: :json)
203+
.thread-actions data-controller="thread-actions" data-thread-actions-topic-id-value=@topic.id data-thread-actions-read-all-url-value=read_all_topic_path(@topic, format: :json) data-thread-actions-unread-all-url-value=unread_all_topic_path(@topic, format: :json)
204204
- if user_signed_in?
205-
button.button.button-secondary.button-small data-action="click->thread-actions#markAllRead" Mark all messages read
205+
button.button.button-secondary.button-small data-action="click->thread-actions#markAllRead" Mark all read
206+
button.button.button-secondary.button-small data-action="click->thread-actions#markAllUnread" Mark all unread
207+
button.button.button-secondary.button-small data-action="click->thread-actions#collapseAll" Collapse all
208+
button.button.button-secondary.button-small data-action="click->thread-actions#expandAll" Show all
206209
= link_to "Jump to latest", "#message-#{last_message.id}", class: "button button-secondary button-small", title: "Jump to latest message", "aria-label": "Jump to latest message"
207210
- if user_signed_in?
208211
= link_to "Jump to unread", "#first-unread", class: "button button-secondary button-small", title: "Jump to first unread message", "aria-label": "Jump to first unread message"
209212

210213
- first_unread_found = false
214+
- unread_inline_count = 0
211215
- @messages.each do |message|
212216
- is_read = user_signed_in? && @read_message_ids&.dig(message.id)
213217
- is_first_unread = false
214218
- if user_signed_in? && !is_read && !first_unread_found
215219
- is_first_unread = true
216220
- first_unread_found = true
217-
- render_mode = (user_signed_in? && is_read) ? :collapsed : :inline
221+
- if is_read
222+
- render_mode = :shell
223+
- elsif unread_inline_count < 20
224+
- render_mode = :inline
225+
- unread_inline_count += 1
226+
- else
227+
- render_mode = :shell
218228
- branch_index = @message_branch_index&.dig(message.id)
219-
= render 'message', message: message, number: @message_numbers[message.id], is_first_unread: is_first_unread, branch_index: branch_index, render_mode: render_mode
229+
= render 'message', message: message, number: @message_numbers[message.id], is_first_unread: is_first_unread, branch_index: branch_index, render_mode: render_mode, is_read: !!is_read
220230

221231
.topic-navigation
222232
= link_to "← Back to Topics", topics_path, class: "back-link"

0 commit comments

Comments
 (0)