FEATURE: implements dates separators for threads (#25335)

This commit creates a shared implementation of the dates computation and moves all the logic (new messages since last visit and dates separator into one single component <ChatMessageSeparator />).

The frontend tests have been removed and only a single system spec has been added for threads as everything is sharing the same implementation and the existing channel specs should catch any regression.
This commit is contained in:
Joffrey JAFFEUX 2024-01-19 16:21:48 +01:00 committed by GitHub
parent d5d0bab19d
commit 2014f1a0b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 239 additions and 222 deletions

View File

@ -161,14 +161,14 @@ module Chat
def self.query_by_date(target_date, channel, messages)
past_messages =
messages
.where("created_at <= ?", target_date)
.where("created_at <= ?", target_date.to_time.utc)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
.to_a
future_messages =
messages
.where("created_at > ?", target_date)
.where("created_at > ?", target_date.to_time.utc)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
.to_a

View File

@ -34,6 +34,7 @@ import {
checkMessageBottomVisibility,
checkMessageTopVisibility,
} from "discourse/plugins/chat/discourse/lib/check-message-visibility";
import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner";
import {
scrollListToBottom,
scrollListToMessage,
@ -111,7 +112,7 @@ export default class ChatChannel extends Component {
@action
didResizePane() {
this.debounceFillPaneAttempt();
this.computeDatesSeparators();
DatesSeparatorsPositioner.apply(this.scrollable);
}
@action
@ -443,13 +444,14 @@ export default class ChatChannel extends Component {
@action
onScroll(state) {
bodyScrollFix();
next(() => {
if (this.#flushIgnoreNextScroll()) {
return;
}
bodyScrollFix();
DatesSeparatorsPositioner.apply(this.scrollable);
this.needsArrow =
(this.messagesLoader.fetchedOnce &&
this.messagesLoader.canLoadMoreFuture) ||
@ -641,50 +643,6 @@ export default class ChatChannel extends Component {
return;
}
@bind
computeDatesSeparators() {
schedule("afterRender", () => {
const dates = [
...this.scrollable.querySelectorAll(".chat-message-separator-date"),
].reverse();
const height = this.scrollable.querySelector(
".chat-messages-container"
).clientHeight;
dates
.map((date, index) => {
const item = { bottom: 0, date };
const line = date.nextElementSibling;
if (index > 0) {
const prevDate = dates[index - 1];
const prevLine = prevDate.nextElementSibling;
item.bottom = height - prevLine.offsetTop;
}
if (dates.length === 1) {
item.height = height;
} else {
if (index === 0) {
item.height = height - line.offsetTop;
} else {
const prevDate = dates[index - 1];
const prevLine = prevDate.nextElementSibling;
item.height =
height - line.offsetTop - (height - prevLine.offsetTop);
}
}
return item;
})
// group all writes at the end
.forEach((item) => {
item.date.style.bottom = item.bottom + "px";
item.date.style.height = item.height + "px";
});
});
}
#cancelHandlers() {
cancel(this._debouncedHighlightOrFetchMessageHandler);
cancel(this._debouncedUpdateLastReadMessageHandler);

View File

@ -1,50 +0,0 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
import trackMessageSeparatorDate from "../modifiers/chat/track-message-separator-date";
export default class ChatMessageSeparatorDate extends Component {
@action
onDateClick() {
return this.args.fetchMessagesByDate?.(
this.args.message.firstMessageOfTheDayAt
);
}
<template>
{{#if @message.formattedFirstMessageDate}}
<div
class={{concatClass
"chat-message-separator-date"
(if @message.newest "with-last-visit")
}}
role="button"
{{on "click" this.onDateClick passive=true}}
>
<div
class="chat-message-separator__text-container"
{{trackMessageSeparatorDate}}
>
<span class="chat-message-separator__text">
{{@message.formattedFirstMessageDate}}
{{#if @message.newest}}
<span class="chat-message-separator__last-visit">
<span
class="chat-message-separator__last-visit-separator"
>-</span>
{{i18n "chat.last_visit"}}
</span>
{{/if}}
</span>
</div>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
{{/if}}
</template>
}

View File

@ -1,21 +0,0 @@
import i18n from "discourse-common/helpers/i18n";
import and from "truth-helpers/helpers/and";
import not from "truth-helpers/helpers/not";
const ChatMessageSeparatorNew = <template>
{{#if (and @message.newest (not @message.formattedFirstMessageDate))}}
<div class="chat-message-separator-new">
<div class="chat-message-separator__text-container">
<span class="chat-message-separator__text">
{{i18n "chat.last_visit"}}
</span>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
</div>
{{/if}}
</template>;
export default ChatMessageSeparatorNew;

View File

@ -0,0 +1,130 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { modifier } from "ember-modifier";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
import I18n from "I18n";
const IS_PINNED_CLASS = "is-pinned";
export default class ChatMessageSeparator extends Component {
track = modifier((element) => {
const intersectionObserver = new IntersectionObserver(
([entry]) => {
if (
entry.isIntersecting &&
entry.intersectionRatio < 1 &&
entry.boundingClientRect.y < entry.intersectionRect.y
) {
entry.target.classList.add(IS_PINNED_CLASS);
} else {
entry.target.classList.remove(IS_PINNED_CLASS);
}
},
{ threshold: [0, 1] }
);
intersectionObserver.observe(element);
return () => {
intersectionObserver?.disconnect();
};
});
@action
onDateClick() {
return this.args.fetchMessagesByDate?.(this.firstMessageOfTheDayAt);
}
@cached
get firstMessageOfTheDayAt() {
const message = this.args.message;
if (!message.previousMessage) {
return this.#startOfDay(message.createdAt);
}
if (
!this.#areDatesOnSameDay(
message.previousMessage.createdAt,
message.createdAt
)
) {
return this.#startOfDay(message.createdAt);
}
}
@cached
get formattedFirstMessageDate() {
if (this.firstMessageOfTheDayAt) {
return this.#calendarDate(this.firstMessageOfTheDayAt);
}
}
#areDatesOnSameDay(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
#startOfDay(date) {
return moment(date).startOf("day").format();
}
#calendarDate(date) {
return moment(date).calendar(moment(), {
sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`,
lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`,
lastWeek: "LL",
sameElse: "LL",
});
}
<template>
{{#if this.formattedFirstMessageDate}}
<div
class={{concatClass
"chat-message-separator-date"
(if @message.newest "with-last-visit")
}}
role="button"
{{on "click" this.onDateClick passive=true}}
>
<div class="chat-message-separator__text-container" {{this.track}}>
<span class="chat-message-separator__text">
{{this.formattedFirstMessageDate}}
{{#if @message.newest}}
<span class="chat-message-separator__last-visit">
<span
class="chat-message-separator__last-visit-separator"
>-</span>
{{i18n "chat.last_visit"}}
</span>
{{/if}}
</span>
</div>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
{{else if @message.newest}}
<div class="chat-message-separator-new">
<div class="chat-message-separator__text-container">
<span class="chat-message-separator__text">
{{i18n "chat.last_visit"}}
</span>
</div>
<div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div>
</div>
</div>
{{/if}}
</template>
}

View File

@ -26,8 +26,7 @@ import ChatMessageInfo from "discourse/plugins/chat/discourse/components/chat/me
import ChatMessageLeftGutter from "discourse/plugins/chat/discourse/components/chat/message/left-gutter";
import ChatMessageInReplyToIndicator from "discourse/plugins/chat/discourse/components/chat-message-in-reply-to-indicator";
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
import ChatMessageSeparatorDate from "discourse/plugins/chat/discourse/components/chat-message-separator-date";
import ChatMessageSeparatorNew from "discourse/plugins/chat/discourse/components/chat-message-separator-new";
import ChatMessageSeparator from "discourse/plugins/chat/discourse/components/chat-message-separator";
import ChatMessageText from "discourse/plugins/chat/discourse/components/chat-message-text";
import ChatMessageThreadIndicator from "discourse/plugins/chat/discourse/components/chat-message-thread-indicator";
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
@ -494,13 +493,10 @@ export default class ChatMessage extends Component {
<template>
{{! template-lint-disable no-invalid-interactive }}
{{#if this.shouldRender}}
{{#if (eq @context "channel")}}
<ChatMessageSeparatorDate
@fetchMessagesByDate={{@fetchMessagesByDate}}
@message={{@message}}
/>
<ChatMessageSeparatorNew @message={{@message}} />
{{/if}}
<ChatMessageSeparator
@fetchMessagesByDate={{@fetchMessagesByDate}}
@message={{@message}}
/>
<div
class={{concatClass

View File

@ -24,6 +24,7 @@ import {
stackingContextFix,
} from "discourse/plugins/chat/discourse/lib/chat-ios-hacks";
import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner";
import {
scrollListToBottom,
scrollListToMessage,
@ -118,6 +119,7 @@ export default class ChatThread extends Component {
}
bodyScrollFix();
DatesSeparatorsPositioner.apply(this.scrollable);
this.needsArrow =
(this.messagesLoader.fetchedOnce &&
@ -191,6 +193,7 @@ export default class ChatThread extends Component {
this._ignoreNextScroll = true;
this.debounceFillPaneAttempt();
this.debounceUpdateLastReadMessage();
DatesSeparatorsPositioner.apply(this.scrollable);
}
async fetchMessages(findArgs = {}) {
@ -362,6 +365,41 @@ export default class ChatThread extends Component {
}
}
@bind
fetchMessagesByDate(date) {
if (this.messagesLoader.loading) {
return;
}
const message = this.messagesManager.findFirstMessageOfDay(new Date(date));
if (message.firstOfResults && this.messagesLoader.canLoadMorePast) {
this.fetchMessages({ target_date: date, direction: FUTURE });
} else {
this.highlightOrFetchMessage(message.id, { position: "center" });
}
}
@action
highlightOrFetchMessage(messageId, options = {}) {
const message = this.messagesManager.findMessage(messageId);
if (message) {
this.scrollToMessageId(
message.id,
Object.assign(
{
highlight: true,
position: "start",
autoExpand: true,
behavior: this.capabilities.isIOS ? "smooth" : null,
},
options
)
);
} else {
this.fetchMessages({ target_message_id: messageId });
}
}
@action
resetComposerMessage() {
this.args.thread.draft = ChatMessage.createDraftMessage(
@ -510,6 +548,7 @@ export default class ChatThread extends Component {
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@fetchMessagesByDate={{this.fetchMessagesByDate}}
@context="thread"
/>
{{/each}}

View File

@ -0,0 +1,46 @@
import { schedule } from "@ember/runloop";
export default class DatesSeparatorsPositioner {
static apply(list) {
schedule("afterRender", () => {
const dates = [
...list.querySelectorAll(".chat-message-separator-date"),
].reverse();
const height = list.querySelector(
".chat-messages-container"
).clientHeight;
dates
.map((date, index) => {
const item = { bottom: 0, date };
const line = date.nextElementSibling;
if (index > 0) {
const prevDate = dates[index - 1];
const prevLine = prevDate.nextElementSibling;
item.bottom = height - prevLine.offsetTop;
}
if (dates.length === 1) {
item.height = height;
} else {
if (index === 0) {
item.height = height - line.offsetTop;
} else {
const prevDate = dates[index - 1];
const prevLine = prevDate.nextElementSibling;
item.height =
height - line.offsetTop - (height - prevLine.offsetTop);
}
}
return item;
})
// group all writes at the end
.forEach((item) => {
item.date.style.bottom = item.bottom + "px";
item.date.style.height = item.height + "px";
});
});
}
}

View File

@ -5,7 +5,6 @@ import Bookmark from "discourse/models/bookmark";
import User from "discourse/models/user";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import discourseLater from "discourse-common/lib/later";
import I18n from "discourse-i18n";
import transformAutolinks from "discourse/plugins/chat/discourse/lib/transform-auto-links";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
@ -161,35 +160,6 @@ export default class ChatMessage {
return this.channel.currentUserMembership?.lastReadMessageId >= this.id;
}
@cached
get firstMessageOfTheDayAt() {
if (!this.previousMessage) {
return this.#startOfDay(this.createdAt);
}
if (
!this.#areDatesOnSameDay(this.previousMessage.createdAt, this.createdAt)
) {
return this.#startOfDay(this.createdAt);
}
}
@cached
get formattedFirstMessageDate() {
if (this.firstMessageOfTheDayAt) {
return this.#calendarDate(this.firstMessageOfTheDayAt);
}
}
#calendarDate(date) {
return moment(date).calendar(moment(), {
sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`,
lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`,
lastWeek: "LL",
sameElse: "LL",
});
}
@cached
get index() {
return this.manager?.messages?.indexOf(this);
@ -370,16 +340,4 @@ export default class ChatMessage {
return User.create(user);
}
#areDatesOnSameDay(a, b) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
#startOfDay(date) {
return moment(date).startOf("day").format();
}
}

View File

@ -195,5 +195,17 @@ describe "Single thread in side panel", type: :system do
expect(side_panel).to have_open_thread(thread)
end
end
context "when messages are separated by a day" do
before do
Fabricate(:chat_message, chat_channel: channel, thread: thread, created_at: 2.days.ago)
end
it "shows a date separator" do
chat_page.visit_thread(thread)
expect(page).to have_selector(".chat-thread .chat-message-separator__text", text: "Today")
end
end
end
end

View File

@ -1,27 +0,0 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
module(
"Discourse Chat | Component | chat-message-separator-date",
function (hooks) {
setupRenderingTest(hooks);
test("first message of the day", async function (assert) {
this.set("date", moment().format("LLL"));
this.set("message", { formattedFirstMessageDate: this.date });
this.set("fetchMessagesByDate", () => {});
await render(
hbs`<ChatMessageSeparatorDate @message={{this.message}} @fetchMessagesByDate={{this.fetchMessagesByDate}} />`
);
assert.strictEqual(
query(".chat-message-separator-date").innerText.trim(),
this.date
);
});
}
);

View File

@ -1,24 +0,0 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "discourse-i18n";
module(
"Discourse Chat | Component | chat-message-separator-new",
function (hooks) {
setupRenderingTest(hooks);
test("newest message", async function (assert) {
this.set("message", { newest: true });
await render(hbs`<ChatMessageSeparatorNew @message={{this.message}} />`);
assert.strictEqual(
query(".chat-message-separator-new").innerText.trim(),
I18n.t("chat.last_visit")
);
});
}
);