Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
142bb12
Add stable message keys and dedupe messages
raucao Oct 1, 2026
a1e260e
Fetch older archive messages on demand
raucao Oct 1, 2026
af129c3
Rework chat scrolling and automatic history loading
raucao Oct 1, 2026
d3ba5a3
Release the scroll maintenance lock on user intent for instant scrolls
raucao Oct 1, 2026
9781091
Prevent rendering if channel is destroyed or changed
raucao Oct 1, 2026
6b00e5e
Enhance scroll button for better accessibility
raucao Oct 1, 2026
4d8e527
Update message count increment condition
raucao Oct 1, 2026
b904f9f
Modify count increment condition in chat scroller
raucao Oct 1, 2026
2ff98c7
Handle failures when loading older messages
raucao Oct 1, 2026
264db07
Address review feedback on history loading and scrolling
raucao Oct 1, 2026
f311a66
Address the latest review: message scope, windowing, async safety
raucao Oct 1, 2026
5f1a530
Animate scroll-to-bottom in JS instead of native smooth scroll
raucao Oct 1, 2026
184519a
Fix scroll-to-bottom button reliability, jump, and ease-out
raucao Oct 1, 2026
04ccba2
Fix message keys so scroll anchors can be restored
raucao Oct 1, 2026
fb08ed8
Reset chat scrolling when the channel object changes
raucao Oct 1, 2026
24611d6
Keep focus in the message list when the jump button hides
raucao Oct 1, 2026
b28123e
Add tests for older-message pagination
raucao Oct 1, 2026
e258409
Remove unnecessary focus outline suppression
raucao Oct 1, 2026
cd40113
Fetch older history for channels shorter than the render window
raucao Oct 1, 2026
9df5739
Base the fill guard on archive cursor progress, not just height
raucao Oct 1, 2026
919963f
Load older history when the list only slightly overflows
raucao Oct 1, 2026
925ca7a
Do not settle at the bottom when a programmatic scroll fell short
raucao Oct 1, 2026
de18c9f
Improve contrast of the history status messages
raucao Oct 1, 2026
b5099f0
Do not start a second scroll-to-bottom animation
raucao Oct 1, 2026
8e029dd
Keep the bottom when older messages prepend while sticking
raucao Oct 1, 2026
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
68 changes: 45 additions & 23 deletions app/components/channel-container.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div id="channel" {{on-render this.onAfterRender}}
{{on-channel-change this.channelChanged}}>
{{on-change @channel this.channelChanged}}>
<main>
<header id="channel-header">
<h2 id="channel-name"
Expand Down Expand Up @@ -28,22 +28,30 @@

<div class="content-container {{@sidebarClass}} {{if @showChannelMenu "channel-menu-open"}}">
<div class="content">
<section id="channel-content" class="main pt-4 pb-8 bg-white text-neutral-800">
{{#if @channel.isLogged}}
<a {{on "click" this.loadPreviousMessages}} class="block px-4 text-sm" role="button">
Load previous messages
</a>
<ChatScroller id="channel-content"
class="main pt-4 pb-8 bg-white text-neutral-800"
tabindex="-1"
@items={{this.renderedMessages}}
@resetKey={{@channel}}
@hasOlder={{this.canLoadOlderMessages}}
@isLoadingOlder={{this.isLoadingOlderMessages}}
@historyCursor={{@channel.searchedPreviousLogsUntilDate}}
@onLoadOlder={{this.loadOlderMessages}}
as |scroll|>
{{#if this.historyLoadFailed}}
<div class="history-load-error pt-4 text-center text-sm text-neutral-600" role="alert">
Couldn’t load older messages.
<button type="button" class="retry" {{on "click" this.retryLoadOlderMessages}}>Retry</button>
</div>
{{else if this.showHistoryStart}}
<div class="history-start pt-4 text-center text-sm text-neutral-600">
Beginning of history
</div>
{{/if}}

<ul class="list-none">
<li>
<ScrollingObserver @rootElement="#channel-content"
@rootMargin={{this.partialRenderingObserverMargin}}
@enabled={{this.partialRenderingEnabled}}
@onIntersect={{this.increaseRenderedMessagesCount}} />
</li>
{{#each this.renderedMessages as |message|}}
<li {{on-render this.messagesUpdated}}>
{{#each this.renderedMessages key="key" as |message|}}
<li data-message-key={{message.key}}>
{{#if (eq message.type "message-chat-me")}}
<MessageChatMe @message={{message}} @channel={{@channel}}
@sendMessage={{@onMessage}}
Expand All @@ -69,16 +77,30 @@
{{/if}}
</li>
{{/each}}
<li>
<ScrollingObserver @rootElement="#channel"
@rootMargin="0px"
@threshold={{1}}
@retriggeringEnabled={{false}}
@onIntersect={{fn this.setAutomaticScrolling true}}
@onDiverge={{fn this.setAutomaticScrolling false}} />
</li>
</ul>
</section>

{{! Zero-height sticky zone so the button is pinned to the viewport edge
without ever contributing to the scroll content's height. If the
button were in flow, adding/removing it would change scrollHeight
and shift the view by the button's height. }}
<div class="jump-to-latest-zone">
{{#unless scroll.isAtBottom}}
<button type="button"
class="jump-to-latest {{if scroll.showJumpToLatest "has-new-messages"}}"
title="Scroll to latest messages"
{{on "click" scroll.scrollToBottom}}>
<span aria-hidden="true"><Icon::chevron-down /></span>
<span class="sr-only">Scroll to latest messages</span>
{{#if scroll.newMessageCount}}
<span>
{{scroll.newMessageCount}}
{{if (eq scroll.newMessageCount 1) "new message" "new messages"}}
</span>
{{/if}}
</button>
{{/unless}}
</div>
</ChatScroller>

<footer>
<MessageInput @message={{@newMessage}}
Expand Down
189 changes: 126 additions & 63 deletions app/components/channel-container.js
Original file line number Diff line number Diff line change
@@ -1,92 +1,167 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { tracked, cached } from '@glimmer/tracking';
import Hammer from 'hammerjs';
import { action } from '@ember/object';

function scrollToBottom () {
let elem = document.getElementById('channel-content');
if (elem) {
elem.scrollTop = elem.scrollHeight;
}
}
const INITIAL_RENDERED_MESSAGES = 50;
const RENDERED_MESSAGES_INCREMENT = 50;

export default class ChannelContainerComponent extends Component {

@service router;
@service coms;

@tracked automaticScrollingEnabled = true;
@tracked partialRenderingEnabled = true;

@tracked partialRenderingObserverMargin = '200px';
// Index into `channel.sortedMessages` at which the rendered window starts.
// We render from here to the end, so appending new messages at the bottom
// never shifts the content the user is looking at.
//
// `null` means "auto": render the latest `INITIAL_RENDERED_MESSAGES`. This is
// the initial state and is important because the IRC archive is loaded
// asynchronously after the component mounts; deriving the window from the live
// message list (rather than sizing it once at mount) means the initial batch
// is windowed correctly. It becomes a fixed index once the user starts reading
// older history.
//
// TODO: bound the window size while detached and shed older DOM nodes
// (Element-style, with height reservation) for very deep history. Session
// message volumes are currently small enough that this is not yet needed.
@tracked renderedStartIndex = null;
@tracked historyLoaded = false;
@tracked historyLoadFailed = false;

@cached
get renderedMessages () {
const all = this.args.channel.sortedMessages;
if (this.renderedStartIndex === null) {
return all.slice(Math.max(0, all.length - INITIAL_RENDERED_MESSAGES));
}
return all.slice(this.renderedStartIndex);
}

@tracked renderedMessagesCount = 0; // maximum number of messages to render
renderedMessagesAddendumAmount = 30; // number of messages to increase rendering count by
get hasOlderMessages () {
return this.args.channel.hasOlderMessages;
}

get renderedMessages () {
if (this.partialRenderingEnabled) {
return this.args.channel.sortedMessages.slice(-this.renderedMessagesCount);
} else {
return this.args.channel.sortedMessages;
// Whether there is anything older to reveal or fetch: either older messages
// already in memory (window not at the start) or more archive pages. A failed
// page stops auto-loading (see `historyLoadFailed`) so we don't retry the same
// failing request in a loop; the user can retry explicitly.
get canLoadOlderMessages () {
if (this.historyLoadFailed) return false;
const channel = this.args.channel;
if (this.renderedStartIndex === null) {
return channel.hasOlderMessages || channel.sortedMessages.length > INITIAL_RENDERED_MESSAGES;
}
return this.renderedStartIndex > 0 || channel.hasOlderMessages;
}

get isLoadingOlderMessages () {
return this.args.channel.loadingOlderMessages;
}

// Only show the "beginning of history" marker once older history has
// actually been loaded/revealed for this channel.
get showHistoryStart () {
return this.historyLoaded && !this.historyLoadFailed && !this.canLoadOlderMessages;
}

@action
channelChanged () {
this.renderedMessagesCount = this.renderedMessagesAddendumAmount;
this.partialRenderingEnabled = true;
this.automaticScrollingEnabled = true;
// `null` = auto-window the latest messages (see `renderedStartIndex`).
this.renderedStartIndex = null;
this.historyLoaded = false;
this.historyLoadFailed = false;

setTimeout(() => {
if (this.isDestroyed || this.isDestroying) return;
this.menu('global', 'hide');
}, 500);
}

@action
messagesUpdated () {
if (this.automaticScrollingEnabled) {
requestAnimationFrame(() => {
if (this.isDestroyed || this.isDestroying) return;
scrollToBottom();
});
}
}

@action
onAfterRender (element) {
// TODO update the config when window is resized
this.partialRenderingObserverMargin = `${element.clientHeight/3}px`;

onAfterRender () {
// We need to define an empty handler for swipe events on the
// #channel-content element, so that the actual handler of the app container
// component gets triggered
Hammer(document.getElementById('channel-content')).on('swipe', function (){});
}

focusMessageInputField () {
const inputEl = document.querySelector('input#message-field');
inputEl.focus();
}
/**
* Reveals the next page of older messages. Older messages already in memory
* are revealed first; a new archive page is fetched once the window reaches
* the beginning of what we have.
*/
@action
async loadOlderMessages () {
const channel = this.args.channel;
if (channel.loadingOlderMessages) return;

if (this.renderedStartIndex === null) {
// Materialize the auto-window and reveal one increment of older messages.
const length = channel.sortedMessages.length;
this.renderedStartIndex = Math.max(
0,
length - INITIAL_RENDERED_MESSAGES - RENDERED_MESSAGES_INCREMENT
);
this.historyLoaded = true;
// Only stop here when the auto-window was actually hiding older in-memory
// messages (`length > INITIAL_RENDERED_MESSAGES`). For a short channel the
// window already showed everything, so this revealed nothing and the DOM
// won't resize to re-trigger auto-loading — fall through and fetch an
// archive page instead of returning without making progress.
if (length > INITIAL_RENDERED_MESSAGES) return;
}

if (this.renderedStartIndex > 0) {
this.renderedStartIndex = Math.max(0, this.renderedStartIndex - RENDERED_MESSAGES_INCREMENT);
this.historyLoaded = true;
return;
}

@tracked loadingPreviousMessages = false;
if (!channel.hasOlderMessages) return;

@action
async loadPreviousMessages () {
if (this.loadingPreviousMessages) return;
this.loadingPreviousMessages = true;
this.automaticScrollingEnabled = false;
const lengthBefore = channel.sortedMessages.length;

channel.loadingOlderMessages = true;
this.historyLoaded = true;
try {
await this.coms.loadArchiveMessages(
this.args.channel,
this.args.channel.searchedPreviousLogsUntilDate,
{ minMessages: 1, maxDays: 1 }
);
await this.coms.loadOlderMessages(channel);
} catch (error) {
// Guard against the channel having changed or the component being torn
// down while the request was in flight, so we don't show the new
// channel's retry error or hide its history loading.
if (this.args.channel !== channel || this.isDestroyed || this.isDestroying) return;
// Stop auto-loading so we don't retry the same failing page in a loop or
// leave an unhandled rejection; the user can retry explicitly.
this.historyLoadFailed = true;
console.error('[channel] Failed to load older messages', error);
return;
} finally {
this.loadingPreviousMessages = false;
channel.loadingOlderMessages = false;
Comment thread
raucao marked this conversation as resolved.
}

// Keep the currently rendered messages visible and reveal a page of the
// newly loaded ones. `ChatScroller` restores the scroll position around it.
if (this.args.channel !== channel || this.isDestroyed || this.isDestroying) return;

const added = channel.sortedMessages.length - lengthBefore;
if (added > 0) {
this.renderedStartIndex = Math.max(0, added - RENDERED_MESSAGES_INCREMENT);
}
Comment thread
Copilot marked this conversation as resolved.
}

@action
retryLoadOlderMessages () {
this.historyLoadFailed = false;
this.loadOlderMessages();
}

focusMessageInputField () {
const inputEl = document.querySelector('input#message-field');
inputEl.focus();
}

// TODO make dynamic based on active sidebar content
get headerNavButtonUsersActive () {
if (window.innerWidth > 900) return true;
Expand Down Expand Up @@ -116,18 +191,6 @@ export default class ChannelContainerComponent extends Component {
this.focusMessageInputField();
}

@action
increaseRenderedMessagesCount () {
let newMessagesCount = this.renderedMessagesCount + this.renderedMessagesAddendumAmount;
this.renderedMessagesCount =newMessagesCount;
this.partialRenderingEnabled = newMessagesCount < this.args.channel.sortedMessages.length;
}

@action
setAutomaticScrolling (state) {
this.automaticScrollingEnabled = state;
}

@action
leaveChannel (channel) {
this.args.onLeaveChannel(channel);
Expand Down
15 changes: 15 additions & 0 deletions app/components/chat-scroller.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div
...attributes
{{chat-scroll
onReady=this.handleReady
onScroll=this.handleScroll
onScrollEnd=this.handleScrollEnd
onContentResize=this.handleContentResize}}
{{on-change @items this.handleItemsChanged}}
{{on-change @resetKey this.handleReset}}
{{on-change @isLoadingOlder this.handleLoadingOlderChanged}}
>
<div class="channel-scroll-content">
{{yield this.scrollApi}}
</div>
</div>
Loading
Loading