Skip to content
Open
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
74 changes: 74 additions & 0 deletions app/assets/stylesheets/application/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,80 @@
margin-bottom: calc(var(--block-space) * 2);
}

/* Room item with lazy loading for involvement buttons */
.room-item {
position: relative;
}

.room-involvement {
display: flex;
align-items: center;
min-width: 20px;
}

.involvement-frame {
display: flex;
align-items: center;
}

.involvement-placeholder {
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
transition: opacity 0.15s ease;
cursor: pointer;
padding: 0.25em;
border-radius: 0.25em;
}

.room-item:hover .involvement-placeholder,
.room-item:focus-within .involvement-placeholder {
opacity: 0.8;
}

.involvement-icon {
display: block;
}

/* Hide placeholder once the frame loads the actual button */
.involvement-frame:has(.btn) .involvement-placeholder {
display: none;
}

/* Sidebar loading state */
.sidebar--loading {
opacity: 0;
transition: opacity 0.2s ease;
}

.sidebar--loaded {
opacity: 1;
}

.sidebar-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: var(--block-space);
min-height: 200px;
}

.sidebar-spinner__icon {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text);
border-radius: 50%;
animation: sidebar-spin 0.8s linear infinite;
}

@keyframes sidebar-spin {
to {
transform: rotate(360deg);
}
}

.room {
color: var(--color-text);
font-weight: normal;
Expand Down
30 changes: 30 additions & 0 deletions app/frontend/controllers/lazy_involvement_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus"

/**
* Lazy loads involvement buttons when the user hovers over a room item.
* This reduces initial page load time by deferring the rendering of
* involvement buttons until they're actually needed.
*/
export default class extends Controller {
static targets = ["trigger", "frame"]
static values = { loaded: Boolean, src: String }

connect() {
this.loadedValue = false
}

load() {
if (this.loadedValue || !this.hasSrcValue) return

this.loadedValue = true

if (this.hasFrameTarget) {
this.frameTarget.src = this.srcValue
}
}

// Also load on focus for keyboard accessibility
loadOnFocus() {
this.load()
}
}
33 changes: 33 additions & 0 deletions app/frontend/controllers/sidebar_loading_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"

/**
* Controls the loading state of the sidebar.
* Shows a loading spinner while the sidebar content is being loaded,
* then transitions to showing the content once ready.
*/
export default class extends Controller {
static targets = ["spinner", "content"]
static values = { loaded: Boolean }

connect() {
// Check if content is already loaded (turbo cache hit)
if (this.hasContentTarget && this.contentTarget.children.length > 0) {
this.showContent()
}
}

showContent() {
this.loadedValue = true
if (this.hasSpinnerTarget) {
this.spinnerTarget.hidden = true
}
if (this.hasContentTarget) {
this.contentTarget.classList.remove("sidebar--loading")
}
}

frameLoaded() {
// Called when the turbo frame finishes loading
this.showContent()
}
}
15 changes: 11 additions & 4 deletions app/views/users/sidebars/rooms/_shared.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
data-sidebar-starred-rooms-target="room" data-involvement="<%= involvement %>"
data-type="list_node"
<%= "hidden" if hide_in_starred_list %>
class="flex gap align-center justify-space-between">
class="flex gap align-center justify-space-between room-item"
data-controller="lazy-involvement"
data-lazy-involvement-src-value="<%= room_involvement_path(room, from_sidebar: true) unless room.conversation_room? %>"
data-action="mouseenter->lazy-involvement#load focusin->lazy-involvement#load">
<%= link_to_room room,
class: [ "flex flex-item-grow gap align-center justify-space-between room full-height txt-nowrap overflow-ellipsis txt-lighter txt-undecorated pad-block position-relative", "unread": unread, "badge": has_notifications ],
style: "padding-block: calc(var(--block-space) / 4);" do %>
Expand All @@ -25,9 +28,13 @@
<% end %>

<% unless room.conversation_room? %>
<span class="txt-small">
<%= turbo_frame_tag dom_id(room, dom_prefix(:sidebar, :involvement)) do %>
<%= button_to_change_involvement(room, involvement, from_sidebar: true) %>
<span class="txt-small room-involvement">
<%= turbo_frame_tag dom_id(room, dom_prefix(:sidebar, :involvement)),
data: { lazy_involvement_target: "frame" },
class: "involvement-frame" do %>
<span class="involvement-placeholder" data-involvement="<%= involvement %>">
<%= image_tag("notification-bell-#{involvement}.svg", aria: { hidden: "true" }, size: 20, class: "involvement-icon") %>
</span>
<% end %>
</span>
<% end %>
Expand Down
33 changes: 33 additions & 0 deletions test/system/sidebar_lazy_loading_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require "application_system_test_case"

class SidebarLazyLoadingTest < ApplicationSystemTestCase
setup do
sign_in "jz@37signals.com"
end

test "involvement buttons load lazily on hover" do
# Initially, sidebar shows placeholder icons (not interactive buttons)
within ".rooms" do
assert_selector ".involvement-placeholder", minimum: 1

# Find a room item and hover over it
room_item = first(".room-item")
room_item.hover

# After hover, the turbo frame should load the actual button
within room_item do
assert_selector "turbo-frame .btn", wait: 5
end
end
end

test "sidebar rooms display correctly on initial load" do
# Verify rooms are visible in the sidebar
within ".rooms" do
assert_selector "[data-type='list_node']", minimum: 1
end

# Verify direct messages section is visible
assert_selector ".directs", visible: true
end
end