PERF: applies optimisations on chat-live pane (#20532)

- group writes when computing separators positions
- shows skeleton only on initial load
- forces date separator to be pinned when first message to prevent a pinned - not pinned - pinned sequence when loading more in past
- relies on `message.visible` property instead of checking `isElementInViewport`
- attempts to load next/prev messages earlier
- do not scroll to on fetch more
- hides `last visit` text while pinned
This commit is contained in:
Joffrey JAFFEUX 2023-03-06 16:42:11 +01:00 committed by GitHub
parent d28390054e
commit b5e736504a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 337 additions and 378 deletions

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::ChatController < Chat::ChatBaseController class Chat::ChatController < Chat::ChatBaseController
PAST_MESSAGE_LIMIT = 20 PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40 FUTURE_MESSAGE_LIMIT = 40
PAST = "past" PAST = "past"
FUTURE = "future" FUTURE = "future"

View File

@ -3,6 +3,7 @@
"chat-live-pane" "chat-live-pane"
(if this.loading "loading") (if this.loading "loading")
(if this.sendingLoading "sending-loading") (if this.sendingLoading "sending-loading")
(unless this.loadedOnce "not-loaded-once")
}} }}
{{did-insert this.setupListeners}} {{did-insert this.setupListeners}}
{{will-destroy this.teardownListeners}} {{will-destroy this.teardownListeners}}
@ -37,16 +38,18 @@
}} }}
></div> ></div>
<div class="chat-messages-scroll chat-messages-container"> <div
class="chat-messages-scroll chat-messages-container"
{{chat/on-throttled-scroll this.computeScrollState (hash delay=50)}}
{{chat/on-throttled-scroll this.resetIdle (hash delay=500)}}
>
<div class="chat-message-actions-desktop-anchor"></div> <div class="chat-message-actions-desktop-anchor"></div>
<div class="chat-messages-container"> <div
class="chat-messages-container"
{{chat/did-mutate-childlist this.computeDatesSeparators}}
>
{{#if this.loadingMorePast}} {{#if this.loadedOnce}}
<ChatSkeleton
@onInsert={{this.onDidInsertSkeleton}}
@onDestroy={{this.onDestroySkeleton}}
/>
{{/if}}
{{#each @channel.messages key="id" as |message|}} {{#each @channel.messages key="id" as |message|}}
<ChatMessage <ChatMessage
@ -65,14 +68,11 @@
@resendStagedMessage={{this.resendStagedMessage}} @resendStagedMessage={{this.resendStagedMessage}}
@didShowMessage={{this.didShowMessage}} @didShowMessage={{this.didShowMessage}}
@didHideMessage={{this.didHideMessage}} @didHideMessage={{this.didHideMessage}}
@forceRendering={{this.forceRendering}}
/> />
{{/each}} {{/each}}
{{else}}
{{#if (or this.loadingMoreFuture)}} <ChatSkeleton />
<ChatSkeleton
@onInsert={{this.onDidInsertSkeleton}}
@onDestroy={{this.onDestroySkeleton}}
/>
{{/if}} {{/if}}
</div> </div>
@ -86,7 +86,7 @@
<ChatScrollToBottomArrow <ChatScrollToBottomArrow
@scrollToBottom={{this.scrollToBottom}} @scrollToBottom={{this.scrollToBottom}}
@hasNewMessages={{this.hasNewMessages}} @hasNewMessages={{this.hasNewMessages}}
@isAlmostDocked={{this.isAlmostDocked}} @show={{or this.needsArrow @channel.canLoadMoreFuture}}
@channel={{@channel}} @channel={{@channel}}
/> />

View File

@ -1,5 +1,4 @@
import { capitalize } from "@ember/string"; import { capitalize } from "@ember/string";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import { cloneJSON } from "discourse-common/lib/object"; import { cloneJSON } from "discourse-common/lib/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
@ -9,7 +8,7 @@ import discourseDebounce from "discourse-common/lib/debounce";
import EmberObject, { action } from "@ember/object"; import EmberObject, { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { cancel, next, schedule, throttle } from "@ember/runloop"; import { cancel, schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
@ -19,14 +18,10 @@ import {
removeOnPresenceChange, removeOnPresenceChange,
} from "discourse/lib/user-presence"; } from "discourse/lib/user-presence";
import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
import { isTesting } from "discourse-common/config/environment";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { getOwner } from "discourse-common/lib/get-owner"; import { getOwner } from "discourse-common/lib/get-owner";
const STICKY_SCROLL_LENIENCE = 100;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 150;
const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500;
const PAST = "past"; const PAST = "past";
const FUTURE = "future"; const FUTURE = "future";
const READ_INTERVAL_MS = 1000; const READ_INTERVAL_MS = 1000;
@ -54,14 +49,18 @@ export default class ChatLivePane extends Component {
@tracked includeHeader = true; @tracked includeHeader = true;
@tracked editingMessage = null; @tracked editingMessage = null;
@tracked replyToMsg = null; @tracked replyToMsg = null;
@tracked hasNewMessages = null; @tracked hasNewMessages = false;
@tracked isDocked = true; @tracked needsArrow = false;
@tracked isAlmostDocked = true;
@tracked loadedOnce = false; @tracked loadedOnce = false;
isAtBottom = true;
isTowardsBottom = false;
isTowardsTop = false;
isAtTop = false;
_loadedChannelId = null; _loadedChannelId = null;
_scrollerEl = null; _scrollerEl = null;
_previousScrollTop = null; _previousScrollTop = 0;
_lastSelectedMessage = null; _lastSelectedMessage = null;
_mentionWarningsSeen = {}; _mentionWarningsSeen = {};
_unreachableGroupMentions = []; _unreachableGroupMentions = [];
@ -70,14 +69,8 @@ export default class ChatLivePane extends Component {
@action @action
setupListeners(element) { setupListeners(element) {
this._scrollerEl = element.querySelector(".chat-messages-scroll"); this._scrollerEl = element.querySelector(".chat-messages-scroll");
this._scrollerEl.addEventListener("scroll", this.onScrollHandler, {
passive: true,
});
window.addEventListener("resize", this.onResizeHandler);
window.addEventListener("wheel", this.onScrollHandler, {
passive: true,
});
window.addEventListener("resize", this.onResizeHandler);
document.addEventListener("scroll", this._forceBodyScroll, { document.addEventListener("scroll", this._forceBodyScroll, {
passive: true, passive: true,
}); });
@ -88,12 +81,8 @@ export default class ChatLivePane extends Component {
} }
@action @action
teardownListeners(element) { teardownListeners() {
element
.querySelector(".chat-messages-scroll")
?.removeEventListener("scroll", this.onScrollHandler);
window.removeEventListener("resize", this.onResizeHandler); window.removeEventListener("resize", this.onResizeHandler);
window.removeEventListener("wheel", this.onScrollHandler);
cancel(this.resizeHandler); cancel(this.resizeHandler);
document.removeEventListener("scroll", this._forceBodyScroll); document.removeEventListener("scroll", this._forceBodyScroll);
removeOnPresenceChange(this.onPresenceChangeCallback); removeOnPresenceChange(this.onPresenceChangeCallback);
@ -101,6 +90,11 @@ export default class ChatLivePane extends Component {
this.requestedTargetMessageId = null; this.requestedTargetMessageId = null;
} }
@action
resetIdle() {
resetIdle();
}
@action @action
updateChannel() { updateChannel() {
// Technically we could keep messages to avoid re-fetching them, but // Technically we could keep messages to avoid re-fetching them, but
@ -120,25 +114,20 @@ export default class ChatLivePane extends Component {
@action @action
loadMessages() { loadMessages() {
this.loadedOnce = false; if (!this.args.channel?.id) {
return;
}
if (this.args.targetMessageId) { if (this.args.targetMessageId) {
this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10); this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10);
} }
if (this.args.channel?.id) {
if (this.requestedTargetMessageId) { if (this.requestedTargetMessageId) {
this.highlightOrFetchMessage(this.requestedTargetMessageId); this.highlightOrFetchMessage(this.requestedTargetMessageId);
} else { } else {
this.fetchMessages({ fetchFromLastMessage: false }); this.fetchMessages({ fetchFromLastMessage: false });
} }
} }
}
@bind
onScrollHandler(event) {
throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, false);
}
@bind @bind
onResizeHandler() { onResizeHandler() {
@ -168,7 +157,8 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.args.channel?.clearMessages(); this.loadedOnce = false;
this._previousScrollTop = 0;
this.loadingMorePast = true; this.loadingMorePast = true;
const findArgs = { pageSize: PAGE_SIZE }; const findArgs = { pageSize: PAGE_SIZE };
@ -193,9 +183,9 @@ export default class ChatLivePane extends Component {
this.args.channel, this.args.channel,
results results
); );
this.args.channel.addMessages(messages);
this.args.channel.messages = messages;
this.args.channel.details = meta; this.args.channel.details = meta;
this.loadedOnce = true;
if (this.requestedTargetMessageId) { if (this.requestedTargetMessageId) {
this.scrollToMessage(findArgs["targetMessageId"], { this.scrollToMessage(findArgs["targetMessageId"], {
@ -206,8 +196,6 @@ export default class ChatLivePane extends Component {
} else if (messages.length) { } else if (messages.length) {
this.scrollToMessage(messages[messages.length - 1].id); this.scrollToMessage(messages[messages.length - 1].id);
} }
this.fillPaneAttempt();
}) })
.catch(this._handleErrors) .catch(this._handleErrors)
.finally(() => { .finally(() => {
@ -215,24 +203,15 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.loadedOnce = true;
this.requestedTargetMessageId = null; this.requestedTargetMessageId = null;
this.loadingMorePast = false; this.loadingMorePast = false;
this.fillPaneAttempt();
}); });
} }
@action
onDestroySkeleton() {
this._iOSFix();
this._throttleComputeSeparators();
}
@action
onDidInsertSkeleton() {
this._computeSeparators(); // this one is not throttled as we need instant feedback
}
@bind @bind
_fetchMoreMessages({ direction, scrollTo = true }) { fetchMoreMessages({ direction }) {
const loadingPast = direction === PAST; const loadingPast = direction === PAST;
const loadingMoreKey = `loadingMore${capitalize(direction)}`; const loadingMoreKey = `loadingMore${capitalize(direction)}`;
@ -272,40 +251,48 @@ export default class ChatLivePane extends Component {
return; return;
} }
// prevents an edge case where user clicks bottom arrow
// just after scrolling to top
if (loadingPast && this.isAtBottom) {
return;
}
const [messages, meta] = this.afterFetchCallback( const [messages, meta] = this.afterFetchCallback(
this.args.channel, this.args.channel,
results results
); );
this.args.channel.addMessages(messages); if (!messages?.length) {
this.args.channel.details = meta;
if (!messages.length) {
return; return;
} }
if (scrollTo) { this.args.channel.addMessages(messages);
if (!loadingPast) { this.args.channel.details = meta;
this.scrollToMessage(messageId, { position: "start" });
} else {
if (this.site.desktopView) {
this.scrollToMessage(messages[messages.length - 1].id);
}
}
}
this.fillPaneAttempt(); // Edge case for IOS to avoid blank screens
// and/or scrolling to bottom losing track of scroll position
schedule("afterRender", () => {
if (
!this._selfDeleted &&
!loadingPast &&
(this.capabilities.isIOS || !this.isScrolling)
) {
this.scrollToMessage(messages[0].id, { position: "end" });
}
});
}) })
.catch(() => { .catch(() => {
this._handleErrors(); this._handleErrors();
}) })
.finally(() => { .finally(() => {
this[loadingMoreKey] = false; this[loadingMoreKey] = false;
this.fillPaneAttempt();
this.computeDatesSeparators();
}); });
} }
@debounce(500, false)
fillPaneAttempt() { fillPaneAttempt() {
next(() => {
if (this._selfDeleted) { if (this._selfDeleted) {
return; return;
} }
@ -319,40 +306,14 @@ export default class ChatLivePane extends Component {
return; return;
} }
schedule("afterRender", () => { const firstMessage = this.args.channel?.messages?.[0];
const firstMessageId = this.args.channel?.messages?.[0]?.id; if (!firstMessage?.visible) {
if (!firstMessageId) {
return; return;
} }
const scroller = document.querySelector(".chat-messages-container"); this.fetchMoreMessages({
const messageContainer = scroller.querySelector(
`.chat-message-container[data-id="${firstMessageId}"]`
);
if (
!scroller ||
!messageContainer ||
!isElementInViewport(messageContainer)
) {
return;
}
this._fetchMoreMessagesThrottled({
direction: PAST, direction: PAST,
scrollTo: false,
}); });
});
});
}
_fetchMoreMessagesThrottled(params) {
throttle(
this,
this._fetchMoreMessages,
params,
FETCH_MORE_MESSAGES_THROTTLE_MS
);
} }
@bind @bind
@ -360,11 +321,15 @@ export default class ChatLivePane extends Component {
const messages = []; const messages = [];
let foundFirstNew = false; let foundFirstNew = false;
results.chat_messages.forEach((messageData) => { results.chat_messages.forEach((messageData, index) => {
if (index === 0) {
messageData.firstOfResults = true;
}
if (this.currentUser.ignored_users) {
// If a message has been hidden it is because the current user is ignoring // If a message has been hidden it is because the current user is ignoring
// the user who sent it, so we want to unconditionally hide it, even if // the user who sent it, so we want to unconditionally hide it, even if
// we are going directly to the target // we are going directly to the target
if (this.currentUser.ignored_users) {
messageData.hidden = this.currentUser.ignored_users.includes( messageData.hidden = this.currentUser.ignored_users.includes(
messageData.user.username messageData.user.username
); );
@ -447,7 +412,7 @@ export default class ChatLivePane extends Component {
}, 2000); }, 2000);
} }
this._iOSFix(() => { this.forceRendering(() => {
messageEl.scrollIntoView({ messageEl.scrollIntoView({
block: opts.position ?? "center", block: opts.position ?? "center",
}); });
@ -459,13 +424,11 @@ export default class ChatLivePane extends Component {
didShowMessage(message) { didShowMessage(message) {
message.visible = true; message.visible = true;
this.updateLastReadMessage(message); this.updateLastReadMessage(message);
this._throttleComputeSeparators();
} }
@action @action
didHideMessage(message) { didHideMessage(message) {
message.visible = false; message.visible = false;
this._throttleComputeSeparators();
} }
@debounce(READ_INTERVAL_MS) @debounce(READ_INTERVAL_MS)
@ -490,67 +453,55 @@ export default class ChatLivePane extends Component {
if (this.args.channel.canLoadMoreFuture) { if (this.args.channel.canLoadMoreFuture) {
this._fetchAndScrollToLatest(); this._fetchAndScrollToLatest();
} else { } else {
if (this._scrollerEl) { this.scrollToMessage(
// Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. this.args.channel.messages[this.args.channel.messages.length - 1].id
// Setting to just 0 doesn't work (it's at 0 by default, so there is no change) );
// Very hacky, but no way to get around this Safari bug
this._scrollerEl.scrollTop = -1;
this._iOSFix(() => {
this._scrollerEl.scrollTop = 0;
this.hasNewMessages = false;
});
}
} }
}); });
} }
onScroll() { @action
computeScrollState(event) {
if (this._selfDeleted) { if (this._selfDeleted) {
return; return;
} }
resetIdle(); cancel(this.onScrollEndedHandler);
if (this.loading || this.loadingMorePast || this.loadingMoreFuture) { this.isScrolling = true;
return;
}
const scrollPosition = Math.abs(this._scrollerEl.scrollTop); const scrollPosition = Math.abs(event.target.scrollTop);
const total = this._scrollerEl.scrollHeight - this._scrollerEl.clientHeight; const total = event.target.scrollHeight - event.target.clientHeight;
const ratio = (scrollPosition / total) * 100;
this.isTowardsTop = ratio < 99 && ratio >= 34;
this.isTowardsBottom = ratio > 1 && ratio <= 4;
this.isAtBottom = ratio <= 1;
this.isAtTop = ratio >= 99;
this.needsArrow = ratio >= 5;
this.isAlmostDocked = scrollPosition / this._scrollerEl.offsetHeight < 0.67; if (this._previousScrollTop - scrollPosition <= 0) {
this.isDocked = scrollPosition <= 1; if (this.isTowardsTop || this.isAtTop) {
this.fetchMoreMessages({ direction: PAST });
if (
this._previousScrollTop - this._scrollerEl.scrollTop >
this._previousScrollTop
) {
const atTop = this._isBetween(
scrollPosition,
total - STICKY_SCROLL_LENIENCE,
total + STICKY_SCROLL_LENIENCE
);
if (atTop) {
this._fetchMoreMessagesThrottled({ direction: PAST });
} }
} else { } else {
const atBottom = this._isBetween( if (this.isTowardsBottom || this.isAtBottom) {
scrollPosition, this.fetchMoreMessages({ direction: FUTURE });
0 + STICKY_SCROLL_LENIENCE, }
0 - STICKY_SCROLL_LENIENCE }
);
if (atBottom) { this._previousScrollTop = scrollPosition;
this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 25);
}
@bind
onScrollEnded() {
this.isScrolling = false;
if (this.isAtBottom) {
this.hasNewMessages = false; this.hasNewMessages = false;
this._fetchMoreMessagesThrottled({ direction: FUTURE });
} }
} }
this._previousScrollTop = this._scrollerEl.scrollTop;
}
_isBetween(target, a, b) { _isBetween(target, a, b) {
const min = Math.min.apply(Math, [a, b]); const min = Math.min.apply(Math, [a, b]);
const max = Math.max.apply(Math, [a, b]); const max = Math.max.apply(Math, [a, b]);
@ -640,7 +591,7 @@ export default class ChatLivePane extends Component {
if (this.args.channel.canLoadMoreFuture) { if (this.args.channel.canLoadMoreFuture) {
// If we can load more messages, we just notice the user of new messages // If we can load more messages, we just notice the user of new messages
this.hasNewMessages = true; this.hasNewMessages = true;
} else if (this._scrollerEl.scrollTop <= 1) { } else if (this.isAtBottom || this.isTowardsBottom) {
// If we are at the bottom, we append the message and scroll to it // If we are at the bottom, we append the message and scroll to it
const message = ChatMessage.create(this.args.channel, data.chat_message); const message = ChatMessage.create(this.args.channel, data.chat_message);
this.args.channel.addMessages([message]); this.args.channel.addMessages([message]);
@ -1107,6 +1058,10 @@ export default class ChatLivePane extends Component {
return; return;
} }
if (this.isScrolling) {
return;
}
if (message?.staged) { if (message?.staged) {
return; return;
} }
@ -1140,20 +1095,6 @@ export default class ChatLivePane extends Component {
} }
} }
this._onHoverMessageDebouncedHandler = discourseDebounce(
this,
this.debouncedOnHoverMessage,
message,
250
);
}
@bind
debouncedOnHoverMessage(message) {
if (this._selfDeleted) {
return;
}
this.hoveredMessageId = this.hoveredMessageId =
message?.id && message.id !== this.hoveredMessageId ? message.id : null; message?.id && message.id !== this.hoveredMessageId ? message.id : null;
} }
@ -1216,6 +1157,7 @@ export default class ChatLivePane extends Component {
} }
_fetchAndScrollToLatest() { _fetchAndScrollToLatest() {
this.loadedOnce = false;
return this.fetchMessages({ return this.fetchMessages({
fetchFromLastMessage: true, fetchFromLastMessage: true,
}); });
@ -1235,12 +1177,14 @@ export default class ChatLivePane extends Component {
// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
// we now use this hack to disable it // we now use this hack to disable it
@bind @bind
_iOSFix(callback) { forceRendering(callback) {
schedule("afterRender", () => {
if (!this._scrollerEl) { if (!this._scrollerEl) {
return; return;
} }
if (this.capabilities.isIOS) { if (this.capabilities.isIOS) {
this._scrollerEl.style.transform = "translateZ(0)";
this._scrollerEl.style.overflow = "hidden"; this._scrollerEl.style.overflow = "hidden";
} }
@ -1253,8 +1197,11 @@ export default class ChatLivePane extends Component {
} }
this._scrollerEl.style.overflow = "auto"; this._scrollerEl.style.overflow = "auto";
}, 25); this._scrollerEl.style.transform = "unset";
this.computeDatesSeparators();
}, 50);
} }
});
} }
@action @action
@ -1300,30 +1247,27 @@ export default class ChatLivePane extends Component {
} }
} }
_throttleComputeSeparators() { @action
throttle(this, this._computeSeparators, 32, false); computeDatesSeparators() {
}
_computeSeparators() {
next(() => {
schedule("afterRender", () => { schedule("afterRender", () => {
const dates = this._scrollerEl.querySelectorAll( const dates = [
".chat-message-separator-date" ...this._scrollerEl.querySelectorAll(".chat-message-separator-date"),
); ].reverse();
const scrollHeight = document.querySelector( const scrollHeight = this._scrollerEl.scrollHeight;
".chat-messages-scroll"
).scrollHeight; dates
const reversedDates = [...dates].reverse(); .map((date, index) => {
// TODO (joffrey): optimize this code to trigger less layout computation const item = { bottom: "0px", date };
reversedDates.forEach((date, index) => {
if (index > 0) { if (index > 0) {
date.style.bottom = item.bottom = scrollHeight - dates[index - 1].offsetTop + "px";
scrollHeight - reversedDates[index - 1].offsetTop + "px";
} else {
date.style.bottom = 0;
} }
date.style.top = date.nextElementSibling.offsetTop + "px"; item.top = date.nextElementSibling.offsetTop + "px";
}); return item;
})
// group all writes at the end
.forEach((item) => {
item.date.style.bottom = item.bottom;
item.date.style.top = item.top;
}); });
}); });
} }

View File

@ -2,18 +2,30 @@
{{#if this.hasUploads}} {{#if this.hasUploads}}
{{html-safe @cooked}} {{html-safe @cooked}}
<Collapser @header={{this.uploadsHeader}}> <Collapser
@header={{this.uploadsHeader}}
@onToggle={{@onToggleCollapse}}
as |collapsed|
>
{{#unless collapsed}}
<div class="chat-uploads"> <div class="chat-uploads">
{{#each @uploads as |upload|}} {{#each @uploads as |upload|}}
<ChatUpload @upload={{upload}} /> <ChatUpload @upload={{upload}} />
{{/each}} {{/each}}
</div> </div>
{{/unless}}
</Collapser> </Collapser>
{{else}} {{else}}
{{#each this.cookedBodies as |cooked|}} {{#each this.cookedBodies as |cooked|}}
{{#if cooked.needsCollapser}} {{#if cooked.needsCollapser}}
<Collapser @header={{cooked.header}}> <Collapser
@header={{cooked.header}}
@onToggle={{@onToggleCollapse}}
as |collapsed|
>
{{#unless collapsed}}
{{cooked.body}} {{cooked.body}}
{{/unless}}
</Collapser> </Collapser>
{{else}} {{else}}
{{cooked.body}} {{cooked.body}}

View File

@ -2,7 +2,7 @@
<div <div
class={{concat-class class={{concat-class
"chat-message-separator-date" "chat-message-separator-date"
(if @message.newest "last-visit") (if @message.newest "with-last-visit")
}} }}
> >
<div <div
@ -10,11 +10,13 @@
{{chat/track-message-separator-date}} {{chat/track-message-separator-date}}
> >
<span class="chat-message-separator__text"> <span class="chat-message-separator__text">
{{@message.firstMessageOfTheDayAt}} <span>{{@message.firstMessageOfTheDayAt}}</span>
{{#if @message.newest}} {{#if @message.newest}}
<span class="chat-message-separator__last-visit">
- -
{{i18n "chat.last_visit"}} {{i18n "chat.last_visit"}}
</span>
{{/if}} {{/if}}
</span> </span>
</div> </div>

View File

@ -1,6 +1,10 @@
<div class="chat-message-text"> <div class="chat-message-text">
{{#if this.isCollapsible}} {{#if this.isCollapsible}}
<ChatMessageCollapser @cooked={{@cooked}} @uploads={{@uploads}} /> <ChatMessageCollapser
@cooked={{@cooked}}
@uploads={{@uploads}}
@onToggleCollapse={{@onToggleCollapse}}
/>
{{else}} {{else}}
{{html-safe @cooked}} {{html-safe @cooked}}
{{/if}} {{/if}}

View File

@ -117,6 +117,7 @@
@cooked={{@message.cooked}} @cooked={{@message.cooked}}
@uploads={{@message.uploads}} @uploads={{@message.uploads}}
@edited={{@message.edited}} @edited={{@message.edited}}
@onToggleCollapse={{fn @forceRendering (noop)}}
> >
{{#if @message.reactions.length}} {{#if @message.reactions.length}}
<div class="chat-message-reaction-list"> <div class="chat-message-reaction-list">

View File

@ -270,16 +270,27 @@ export default class ChatMessage extends Component {
} }
get hideUserInfo() { get hideUserInfo() {
const message = this.args.message;
const previousMessage = message?.previousMessage;
if (!previousMessage) {
return false;
}
// this is a micro optimization to avoid layout changes when we load more messages
if (message?.firstOfResults) {
return false;
}
return ( return (
!this.args.message?.chatWebhookEvent && !message?.chatWebhookEvent &&
!this.args.message?.inReplyTo && (!message?.inReplyTo ||
!this.args.message?.previousMessage?.deletedAt && message?.inReplyTo?.user?.id !== message?.user?.id) &&
!message?.previousMessage?.deletedAt &&
Math.abs( Math.abs(
new Date(this.args.message?.createdAt) - new Date(message?.createdAt) - new Date(previousMessage?.createdAt)
new Date(this.args.message?.createdAt)
) < 300000 && // If the time between messages is over 5 minutes, break. ) < 300000 && // If the time between messages is over 5 minutes, break.
this.args.message?.user?.id === message?.user?.id === message?.previousMessage?.user?.id
this.args.message?.previousMessage?.user?.id
); );
} }
@ -506,6 +517,8 @@ export default class ChatMessage extends Component {
this.currentUser.id this.currentUser.id
); );
this.args.forceRendering?.();
return ajax( return ajax(
`/chat/${this.args.message.channelId}/react/${this.args.message.id}`, `/chat/${this.args.message.channelId}/react/${this.args.message.id}`,
{ {

View File

@ -3,10 +3,7 @@
class={{concat-class class={{concat-class
"btn-flat" "btn-flat"
"chat-scroll-to-bottom" "chat-scroll-to-bottom"
(if (if (or @show @hasNewMessages) "visible")
(or (not @isAlmostDocked) @hasNewMessages @channel.canLoadMoreFuture)
"visible"
)
}} }}
@action={{@scrollToBottom}} @action={{@scrollToBottom}}
> >

View File

@ -1,8 +1,4 @@
<div <div class="chat-skeleton -animation">
class="chat-skeleton -animation"
{{did-insert @onInsert}}
{{will-destroy @onDestroy}}
>
{{#each this.placeholders as |placeholder|}} {{#each this.placeholders as |placeholder|}}
<div class="chat-skeleton__body"> <div class="chat-skeleton__body">
<div class="chat-skeleton__message"> <div class="chat-skeleton__message">

View File

@ -16,6 +16,11 @@
{{/if}} {{/if}}
</div> </div>
<div class={{if this.collapsed "hidden" ""}}> <div
{{yield}} class={{concat-class
"chat-message-collapser-body"
(if this.collapsed "hidden")
}}
>
{{yield this.collapsed}}
</div> </div>

View File

@ -6,14 +6,17 @@ export default Component.extend({
collapsed: false, collapsed: false,
header: null, header: null,
onToggle: null,
@action @action
open() { open() {
this.set("collapsed", false); this.set("collapsed", false);
this.onToggle?.(false);
}, },
@action @action
close() { close() {
this.set("collapsed", true); this.set("collapsed", true);
this.onToggle?.(true);
}, },
}); });

View File

@ -47,11 +47,13 @@ export default class ChatMessage {
@tracked availableFlags; @tracked availableFlags;
@tracked newest = false; @tracked newest = false;
@tracked highlighted = false; @tracked highlighted = false;
@tracked firstOfResults = false;
constructor(channel, args = {}) { constructor(channel, args = {}) {
this.channel = channel; this.channel = channel;
this.id = args.id; this.id = args.id;
this.newest = args.newest; this.newest = args.newest;
this.firstOfResults = args.firstOfResults;
this.staged = args.staged; this.staged = args.staged;
this.edited = args.edited; this.edited = args.edited;
this.availableFlags = args.available_flags; this.availableFlags = args.available_flags;

View File

@ -0,0 +1,24 @@
import Modifier from "ember-modifier";
import { registerDestructor } from "@ember/destroyable";
export default class ChatDidMutateChildlist extends Modifier {
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [callback]) {
this.mutationObserver = new MutationObserver(() => {
callback();
});
this.mutationObserver.observe(element, {
childList: true,
subtree: true,
});
}
cleanup() {
this.mutationObserver?.disconnect();
}
}

View File

@ -0,0 +1,36 @@
import Modifier from "ember-modifier";
import { registerDestructor } from "@ember/destroyable";
import { cancel, throttle } from "@ember/runloop";
import { bind } from "discourse-common/utils/decorators";
export default class ChatOnThrottledScroll extends Modifier {
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [callback, options]) {
this.element = element;
this.callback = callback;
this.options = options;
this.element.addEventListener("scroll", this.throttledCallback, {
passive: true,
});
}
@bind
throttledCallback(event) {
this.throttledHandler = throttle(
this,
this.callback,
event,
this.options.delay ?? 100,
this.options.immediate ?? false
);
}
cleanup() {
cancel(this.throttledHandler);
this.element.removeEventListener("scroll", this.throttledCallback);
}
}

View File

@ -243,7 +243,7 @@ export default class ChatApi extends Service {
* @param {integer} data.pageSize - Max number of messages to fetch. * @param {integer} data.pageSize - Max number of messages to fetch.
* @returns {Promise} * @returns {Promise}
*/ */
async messages(channelId, data = {}) { messages(channelId, data = {}) {
let path; let path;
const args = {}; const args = {};

View File

@ -134,6 +134,10 @@ export default class ChatChannelsManager extends Service {
} }
#cache(channel) { #cache(channel) {
if (!channel) {
return;
}
this._cached[channel.id] = channel; this._cached[channel.id] = channel;
} }

View File

@ -44,7 +44,7 @@
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
&.last-visit { &.with-last-visit {
& + .chat-message-separator__line-container { & + .chat-message-separator__line-container {
.chat-message-separator__line { .chat-message-separator__line {
border-color: var(--danger-medium); border-color: var(--danger-medium);
@ -57,11 +57,16 @@
position: sticky; position: sticky;
top: -1px; top: -1px;
&.is-pinned { &.is-pinned,
&.is-force-pinned {
.chat-message-separator__text { .chat-message-separator__text {
border: 1px solid var(--primary-medium); border: 1px solid var(--primary-medium);
border-radius: 3px; border-radius: 3px;
} }
.chat-message-separator__last-visit {
display: none;
}
} }
} }

View File

@ -145,9 +145,13 @@ $float-height: 530px;
word-wrap: break-word; word-wrap: break-word;
white-space: normal; white-space: normal;
position: relative; position: relative;
will-change: transform;
transform: translateZ(0);
.chat-message-container { .chat-message-container {
display: grid; display: grid;
will-change: transform;
transform: translateZ(0);
&.selecting-messages { &.selecting-messages {
grid-template-columns: 1.5em 1fr; grid-template-columns: 1.5em 1fr;
@ -332,7 +336,7 @@ $float-height: 530px;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
flex-direction: column; flex-direction: column;
bottom: -75px; bottom: -25px;
background: none; background: none;
opacity: 0; opacity: 0;
transition: opacity 0.25s ease, transform 0.5s ease; transition: opacity 0.25s ease, transform 0.5s ease;
@ -350,7 +354,7 @@ $float-height: 530px;
} }
&.visible { &.visible {
transform: translateY(-75px) scale(1); transform: translateY(-32px) scale(1);
opacity: 0.8; opacity: 0.8;
} }

View File

@ -52,7 +52,6 @@ RSpec.describe "Bookmark message", type: :system, js: true do
context "when mobile", mobile: true do context "when mobile", mobile: true do
it "allows to bookmark a message" do it "allows to bookmark a message" do
chat.visit_channel(category_channel_1) chat.visit_channel(category_channel_1)
expect(channel).to have_no_loading_skeleton
i = 0.5 i = 0.5
try_until_success(timeout: 20) do try_until_success(timeout: 20) do

View File

@ -35,7 +35,6 @@ RSpec.describe "Chat channel", type: :system, js: true do
it "allows to edit this message once persisted" do it "allows to edit this message once persisted" do
chat.visit_channel(channel_1) chat.visit_channel(channel_1)
expect(channel).to have_no_loading_skeleton
channel.send_message("aaaaaaaaaaaaaaaaaaaa") channel.send_message("aaaaaaaaaaaaaaaaaaaa")
expect(page).to have_no_css(".chat-message-staged") expect(page).to have_no_css(".chat-message-staged")
last_message = find(".chat-message-container:last-child") last_message = find(".chat-message-container:last-child")

View File

@ -16,7 +16,6 @@ RSpec.describe "Deleted message", type: :system, js: true do
context "when deleting a message" do context "when deleting a message" do
it "shows as deleted" do it "shows as deleted" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
expect(channel_page).to have_no_loading_skeleton
channel_page.send_message("aaaaaaaaaaaaaaaaaaaa") channel_page.send_message("aaaaaaaaaaaaaaaaaaaa")
expect(page).to have_no_css(".chat-message-staged") expect(page).to have_no_css(".chat-message-staged")
last_message = find(".chat-message-container:last-child") last_message = find(".chat-message-container:last-child")

View File

@ -25,7 +25,6 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
it "searches for channels, categories, and tags with # and prioritises channels in the results" do it "searches for channels, categories, and tags with # and prioritises channels in the results" do
chat_page.visit_channel(channel1) chat_page.visit_channel(channel1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer("this is #ra") chat_channel_page.type_in_composer("this is #ra")
expect(page).to have_css( expect(page).to have_css(
".hashtag-autocomplete .hashtag-autocomplete__option .hashtag-autocomplete__link", ".hashtag-autocomplete .hashtag-autocomplete__option .hashtag-autocomplete__link",
@ -53,7 +52,6 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
it "cooks the hashtags for channels, categories, and tags serverside when the chat message is saved to the database" do it "cooks the hashtags for channels, categories, and tags serverside when the chat message is saved to the database" do
chat_page.visit_channel(channel1) chat_page.visit_channel(channel1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer( chat_channel_page.type_in_composer(
"this is #random and this is #raspberry-beret and this is #razed which is cool", "this is #random and this is #raspberry-beret and this is #razed which is cool",
) )

View File

@ -21,7 +21,6 @@ RSpec.describe "Mentions warnings", type: :system, js: true do
it "displays a warning" do it "displays a warning" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer("@#{admin_mentionable_group.name} ") chat_channel_page.type_in_composer("@#{admin_mentionable_group.name} ")
expect(page).to have_css(".chat-mention-warnings") expect(page).to have_css(".chat-mention-warnings")
@ -46,7 +45,6 @@ RSpec.describe "Mentions warnings", type: :system, js: true do
it "displays a warning" do it "displays a warning" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer("@#{publicly_mentionable_group.name} ") chat_channel_page.type_in_composer("@#{publicly_mentionable_group.name} ")
expect(page).to have_css(".chat-mention-warnings") expect(page).to have_css(".chat-mention-warnings")
@ -61,7 +59,6 @@ RSpec.describe "Mentions warnings", type: :system, js: true do
it "displays a warning" do it "displays a warning" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer( chat_channel_page.type_in_composer(
"@#{user_2.username} @#{publicly_mentionable_group.name} ", "@#{user_2.username} @#{publicly_mentionable_group.name} ",
) )

View File

@ -19,6 +19,7 @@ module PageObjects
def visit_channel(channel, mobile: false) def visit_channel(channel, mobile: false)
visit(channel.url + (mobile ? "?mobile_view=1" : "")) visit(channel.url + (mobile ? "?mobile_view=1" : ""))
has_no_css?(".not-loaded-once")
has_no_css?(".chat-skeleton") has_no_css?(".chat-skeleton")
end end

View File

@ -1,42 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Reply indicator", type: :system, js: true do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:current_user) { Fabricate(:admin) }
before do
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
end
context "when clicking on a reply indicator of a loaded message" do
fab!(:replied_to_message) do
Fabricate(:chat_message, chat_channel: channel_1, created_at: 2.hours.ago)
end
fab!(:reply) do
Fabricate(
:chat_message,
chat_channel: channel_1,
in_reply_to: replied_to_message,
created_at: 1.minute.ago,
)
end
before do
10.times { Fabricate(:chat_message, chat_channel: channel_1, created_at: 1.hour.ago) }
end
it "highlights the message without refreshing the pane" do
chat_page.visit_channel(channel_1)
find("[data-id='#{reply.id}'] .chat-reply").click
expect(page).to have_no_selector(".chat-skeleton")
expect(page).to have_selector("[data-id='#{replied_to_message.id}'].highlighted", wait: 0.1)
end
end
end

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Replying indicator", type: :system, js: true do
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:current_user) { Fabricate(:user) }
fab!(:other_user) { Fabricate(:user) }
let(:chat) { PageObjects::Pages::Chat.new }
before do
channel_1.add(current_user)
channel_1.add(other_user)
chat_system_bootstrap
sign_in(current_user)
end
context "when on a channel" do
context "when another user is replying" do
it "shows the replying indicator" do
using_session(:user_1) do
sign_in(other_user)
chat.visit_channel(channel_1)
find(".chat-composer-input").fill_in(with: "hello there")
end
chat.visit_channel(channel_1)
expect(page).to have_selector(
".chat-replying-indicator",
text: I18n.t("js.chat.replying_indicator.single_user", username: other_user.username),
)
end
end
end
end

View File

@ -93,8 +93,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
it "quotes the message" do it "quotes the message" do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
clip_text = copy_messages_to_clipboard(message_1) clip_text = copy_messages_to_clipboard(message_1)
topic_page.visit_topic_and_open_composer(post_1.topic) topic_page.visit_topic_and_open_composer(post_1.topic)
topic_page.fill_in_composer("This is a new post!\n\n" + clip_text) topic_page.fill_in_composer("This is a new post!\n\n" + clip_text)
@ -117,8 +115,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
it "quotes the messages" do it "quotes the messages" do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
clip_text = copy_messages_to_clipboard([message_1, message_2]) clip_text = copy_messages_to_clipboard([message_1, message_2])
topic_page.visit_topic_and_open_composer(post_1.topic) topic_page.visit_topic_and_open_composer(post_1.topic)
topic_page.fill_in_composer("This is a new post!\n\n" + clip_text) topic_page.fill_in_composer("This is a new post!\n\n" + clip_text)
@ -149,8 +145,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
it "works" do it "works" do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
clip_text = copy_messages_to_clipboard(message_1) clip_text = copy_messages_to_clipboard(message_1)
topic_page.visit_topic_and_open_composer(post_1.topic) topic_page.visit_topic_and_open_composer(post_1.topic)
topic_page.fill_in_composer(clip_text) topic_page.fill_in_composer(clip_text)
@ -167,8 +161,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
it "quotes the message" do it "quotes the message" do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
clip_text = copy_messages_to_clipboard(message_1) clip_text = copy_messages_to_clipboard(message_1)
click_selection_button("cancel") click_selection_button("cancel")
chat_channel_page.send_message(clip_text) chat_channel_page.send_message(clip_text)
@ -191,8 +183,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
it "opens the topic composer with correct state" do it "opens the topic composer with correct state" do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
select_message_desktop(message_1) select_message_desktop(message_1)
click_selection_button("quote") click_selection_button("quote")
@ -219,8 +209,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
mobile: true do mobile: true do
chat_page.visit_channel(chat_channel_1) chat_page.visit_channel(chat_channel_1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.click_message_action_mobile(message_1, "selectMessage") chat_channel_page.click_message_action_mobile(message_1, "selectMessage")
click_selection_button("quote") click_selection_button("quote")

View File

@ -57,6 +57,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
onHoverMessage: () => {}, onHoverMessage: () => {},
didShowMessage: () => {}, didShowMessage: () => {},
didHideMessage: () => {}, didHideMessage: () => {},
forceRendering: () => {},
}; };
} }
@ -75,6 +76,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
@onHoverMessage={{this.onHoverMessage}} @onHoverMessage={{this.onHoverMessage}}
@didShowMessage={{this.didShowMessage}} @didShowMessage={{this.didShowMessage}}
@didHideMessage={{this.didHideMessage}} @didHideMessage={{this.didHideMessage}}
@didHideMessage={{this.didHideMessage}}
@forceRendering={{this.forceRendering}}
/> />
`; `;