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:
parent
d28390054e
commit
b5e736504a
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatController < Chat::ChatBaseController
|
||||
PAST_MESSAGE_LIMIT = 20
|
||||
PAST_MESSAGE_LIMIT = 40
|
||||
FUTURE_MESSAGE_LIMIT = 40
|
||||
PAST = "past"
|
||||
FUTURE = "future"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"chat-live-pane"
|
||||
(if this.loading "loading")
|
||||
(if this.sendingLoading "sending-loading")
|
||||
(unless this.loadedOnce "not-loaded-once")
|
||||
}}
|
||||
{{did-insert this.setupListeners}}
|
||||
{{will-destroy this.teardownListeners}}
|
||||
|
@ -37,16 +38,18 @@
|
|||
}}
|
||||
></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-messages-container">
|
||||
<div
|
||||
class="chat-messages-container"
|
||||
{{chat/did-mutate-childlist this.computeDatesSeparators}}
|
||||
>
|
||||
|
||||
{{#if this.loadingMorePast}}
|
||||
<ChatSkeleton
|
||||
@onInsert={{this.onDidInsertSkeleton}}
|
||||
@onDestroy={{this.onDestroySkeleton}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.loadedOnce}}
|
||||
|
||||
{{#each @channel.messages key="id" as |message|}}
|
||||
<ChatMessage
|
||||
|
@ -65,14 +68,11 @@
|
|||
@resendStagedMessage={{this.resendStagedMessage}}
|
||||
@didShowMessage={{this.didShowMessage}}
|
||||
@didHideMessage={{this.didHideMessage}}
|
||||
@forceRendering={{this.forceRendering}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if (or this.loadingMoreFuture)}}
|
||||
<ChatSkeleton
|
||||
@onInsert={{this.onDidInsertSkeleton}}
|
||||
@onDestroy={{this.onDestroySkeleton}}
|
||||
/>
|
||||
{{else}}
|
||||
<ChatSkeleton />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
|
@ -86,7 +86,7 @@
|
|||
<ChatScrollToBottomArrow
|
||||
@scrollToBottom={{this.scrollToBottom}}
|
||||
@hasNewMessages={{this.hasNewMessages}}
|
||||
@isAlmostDocked={{this.isAlmostDocked}}
|
||||
@show={{or this.needsArrow @channel.canLoadMoreFuture}}
|
||||
@channel={{@channel}}
|
||||
/>
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { capitalize } from "@ember/string";
|
||||
import isElementInViewport from "discourse/lib/is-element-in-viewport";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
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 { ajax } from "discourse/lib/ajax";
|
||||
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 { inject as service } from "@ember/service";
|
||||
import { Promise } from "rsvp";
|
||||
|
@ -19,14 +18,10 @@ import {
|
|||
removeOnPresenceChange,
|
||||
} from "discourse/lib/user-presence";
|
||||
import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
|
||||
const STICKY_SCROLL_LENIENCE = 100;
|
||||
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 FUTURE = "future";
|
||||
const READ_INTERVAL_MS = 1000;
|
||||
|
@ -54,14 +49,18 @@ export default class ChatLivePane extends Component {
|
|||
@tracked includeHeader = true;
|
||||
@tracked editingMessage = null;
|
||||
@tracked replyToMsg = null;
|
||||
@tracked hasNewMessages = null;
|
||||
@tracked isDocked = true;
|
||||
@tracked isAlmostDocked = true;
|
||||
@tracked hasNewMessages = false;
|
||||
@tracked needsArrow = false;
|
||||
@tracked loadedOnce = false;
|
||||
|
||||
isAtBottom = true;
|
||||
isTowardsBottom = false;
|
||||
isTowardsTop = false;
|
||||
isAtTop = false;
|
||||
|
||||
_loadedChannelId = null;
|
||||
_scrollerEl = null;
|
||||
_previousScrollTop = null;
|
||||
_previousScrollTop = 0;
|
||||
_lastSelectedMessage = null;
|
||||
_mentionWarningsSeen = {};
|
||||
_unreachableGroupMentions = [];
|
||||
|
@ -70,14 +69,8 @@ export default class ChatLivePane extends Component {
|
|||
@action
|
||||
setupListeners(element) {
|
||||
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, {
|
||||
passive: true,
|
||||
});
|
||||
|
@ -88,12 +81,8 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
teardownListeners(element) {
|
||||
element
|
||||
.querySelector(".chat-messages-scroll")
|
||||
?.removeEventListener("scroll", this.onScrollHandler);
|
||||
teardownListeners() {
|
||||
window.removeEventListener("resize", this.onResizeHandler);
|
||||
window.removeEventListener("wheel", this.onScrollHandler);
|
||||
cancel(this.resizeHandler);
|
||||
document.removeEventListener("scroll", this._forceBodyScroll);
|
||||
removeOnPresenceChange(this.onPresenceChangeCallback);
|
||||
|
@ -101,6 +90,11 @@ export default class ChatLivePane extends Component {
|
|||
this.requestedTargetMessageId = null;
|
||||
}
|
||||
|
||||
@action
|
||||
resetIdle() {
|
||||
resetIdle();
|
||||
}
|
||||
|
||||
@action
|
||||
updateChannel() {
|
||||
// Technically we could keep messages to avoid re-fetching them, but
|
||||
|
@ -120,25 +114,20 @@ export default class ChatLivePane extends Component {
|
|||
|
||||
@action
|
||||
loadMessages() {
|
||||
this.loadedOnce = false;
|
||||
if (!this.args.channel?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.args.targetMessageId) {
|
||||
this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10);
|
||||
}
|
||||
|
||||
if (this.args.channel?.id) {
|
||||
if (this.requestedTargetMessageId) {
|
||||
this.highlightOrFetchMessage(this.requestedTargetMessageId);
|
||||
} else {
|
||||
this.fetchMessages({ fetchFromLastMessage: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
onScrollHandler(event) {
|
||||
throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, false);
|
||||
}
|
||||
|
||||
@bind
|
||||
onResizeHandler() {
|
||||
|
@ -168,7 +157,8 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
this.args.channel?.clearMessages();
|
||||
this.loadedOnce = false;
|
||||
this._previousScrollTop = 0;
|
||||
this.loadingMorePast = true;
|
||||
|
||||
const findArgs = { pageSize: PAGE_SIZE };
|
||||
|
@ -193,9 +183,9 @@ export default class ChatLivePane extends Component {
|
|||
this.args.channel,
|
||||
results
|
||||
);
|
||||
this.args.channel.addMessages(messages);
|
||||
|
||||
this.args.channel.messages = messages;
|
||||
this.args.channel.details = meta;
|
||||
this.loadedOnce = true;
|
||||
|
||||
if (this.requestedTargetMessageId) {
|
||||
this.scrollToMessage(findArgs["targetMessageId"], {
|
||||
|
@ -206,8 +196,6 @@ export default class ChatLivePane extends Component {
|
|||
} else if (messages.length) {
|
||||
this.scrollToMessage(messages[messages.length - 1].id);
|
||||
}
|
||||
|
||||
this.fillPaneAttempt();
|
||||
})
|
||||
.catch(this._handleErrors)
|
||||
.finally(() => {
|
||||
|
@ -215,24 +203,15 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
this.loadedOnce = true;
|
||||
this.requestedTargetMessageId = null;
|
||||
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
|
||||
_fetchMoreMessages({ direction, scrollTo = true }) {
|
||||
fetchMoreMessages({ direction }) {
|
||||
const loadingPast = direction === PAST;
|
||||
const loadingMoreKey = `loadingMore${capitalize(direction)}`;
|
||||
|
||||
|
@ -272,40 +251,48 @@ export default class ChatLivePane extends Component {
|
|||
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(
|
||||
this.args.channel,
|
||||
results
|
||||
);
|
||||
|
||||
this.args.channel.addMessages(messages);
|
||||
this.args.channel.details = meta;
|
||||
|
||||
if (!messages.length) {
|
||||
if (!messages?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTo) {
|
||||
if (!loadingPast) {
|
||||
this.scrollToMessage(messageId, { position: "start" });
|
||||
} else {
|
||||
if (this.site.desktopView) {
|
||||
this.scrollToMessage(messages[messages.length - 1].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.args.channel.addMessages(messages);
|
||||
this.args.channel.details = meta;
|
||||
|
||||
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(() => {
|
||||
this._handleErrors();
|
||||
})
|
||||
.finally(() => {
|
||||
this[loadingMoreKey] = false;
|
||||
this.fillPaneAttempt();
|
||||
this.computeDatesSeparators();
|
||||
});
|
||||
}
|
||||
|
||||
@debounce(500, false)
|
||||
fillPaneAttempt() {
|
||||
next(() => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
@ -319,40 +306,14 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const firstMessageId = this.args.channel?.messages?.[0]?.id;
|
||||
if (!firstMessageId) {
|
||||
const firstMessage = this.args.channel?.messages?.[0];
|
||||
if (!firstMessage?.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scroller = document.querySelector(".chat-messages-container");
|
||||
const messageContainer = scroller.querySelector(
|
||||
`.chat-message-container[data-id="${firstMessageId}"]`
|
||||
);
|
||||
|
||||
if (
|
||||
!scroller ||
|
||||
!messageContainer ||
|
||||
!isElementInViewport(messageContainer)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fetchMoreMessagesThrottled({
|
||||
this.fetchMoreMessages({
|
||||
direction: PAST,
|
||||
scrollTo: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_fetchMoreMessagesThrottled(params) {
|
||||
throttle(
|
||||
this,
|
||||
this._fetchMoreMessages,
|
||||
params,
|
||||
FETCH_MORE_MESSAGES_THROTTLE_MS
|
||||
);
|
||||
}
|
||||
|
||||
@bind
|
||||
|
@ -360,11 +321,15 @@ export default class ChatLivePane extends Component {
|
|||
const messages = [];
|
||||
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
|
||||
// the user who sent it, so we want to unconditionally hide it, even if
|
||||
// we are going directly to the target
|
||||
if (this.currentUser.ignored_users) {
|
||||
messageData.hidden = this.currentUser.ignored_users.includes(
|
||||
messageData.user.username
|
||||
);
|
||||
|
@ -447,7 +412,7 @@ export default class ChatLivePane extends Component {
|
|||
}, 2000);
|
||||
}
|
||||
|
||||
this._iOSFix(() => {
|
||||
this.forceRendering(() => {
|
||||
messageEl.scrollIntoView({
|
||||
block: opts.position ?? "center",
|
||||
});
|
||||
|
@ -459,13 +424,11 @@ export default class ChatLivePane extends Component {
|
|||
didShowMessage(message) {
|
||||
message.visible = true;
|
||||
this.updateLastReadMessage(message);
|
||||
this._throttleComputeSeparators();
|
||||
}
|
||||
|
||||
@action
|
||||
didHideMessage(message) {
|
||||
message.visible = false;
|
||||
this._throttleComputeSeparators();
|
||||
}
|
||||
|
||||
@debounce(READ_INTERVAL_MS)
|
||||
|
@ -490,67 +453,55 @@ export default class ChatLivePane extends Component {
|
|||
if (this.args.channel.canLoadMoreFuture) {
|
||||
this._fetchAndScrollToLatest();
|
||||
} else {
|
||||
if (this._scrollerEl) {
|
||||
// Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom.
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
this.scrollToMessage(
|
||||
this.args.channel.messages[this.args.channel.messages.length - 1].id
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
@action
|
||||
computeScrollState(event) {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetIdle();
|
||||
cancel(this.onScrollEndedHandler);
|
||||
|
||||
if (this.loading || this.loadingMorePast || this.loadingMoreFuture) {
|
||||
return;
|
||||
}
|
||||
this.isScrolling = true;
|
||||
|
||||
const scrollPosition = Math.abs(this._scrollerEl.scrollTop);
|
||||
const total = this._scrollerEl.scrollHeight - this._scrollerEl.clientHeight;
|
||||
const scrollPosition = Math.abs(event.target.scrollTop);
|
||||
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;
|
||||
this.isDocked = scrollPosition <= 1;
|
||||
|
||||
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 });
|
||||
if (this._previousScrollTop - scrollPosition <= 0) {
|
||||
if (this.isTowardsTop || this.isAtTop) {
|
||||
this.fetchMoreMessages({ direction: PAST });
|
||||
}
|
||||
} else {
|
||||
const atBottom = this._isBetween(
|
||||
scrollPosition,
|
||||
0 + STICKY_SCROLL_LENIENCE,
|
||||
0 - STICKY_SCROLL_LENIENCE
|
||||
);
|
||||
if (this.isTowardsBottom || this.isAtBottom) {
|
||||
this.fetchMoreMessages({ direction: FUTURE });
|
||||
}
|
||||
}
|
||||
|
||||
if (atBottom) {
|
||||
this._previousScrollTop = scrollPosition;
|
||||
this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 25);
|
||||
}
|
||||
|
||||
@bind
|
||||
onScrollEnded() {
|
||||
this.isScrolling = false;
|
||||
|
||||
if (this.isAtBottom) {
|
||||
this.hasNewMessages = false;
|
||||
this._fetchMoreMessagesThrottled({ direction: FUTURE });
|
||||
}
|
||||
}
|
||||
|
||||
this._previousScrollTop = this._scrollerEl.scrollTop;
|
||||
}
|
||||
|
||||
_isBetween(target, a, b) {
|
||||
const min = Math.min.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 we can load more messages, we just notice the user of new messages
|
||||
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
|
||||
const message = ChatMessage.create(this.args.channel, data.chat_message);
|
||||
this.args.channel.addMessages([message]);
|
||||
|
@ -1107,6 +1058,10 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.isScrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.staged) {
|
||||
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 =
|
||||
message?.id && message.id !== this.hoveredMessageId ? message.id : null;
|
||||
}
|
||||
|
@ -1216,6 +1157,7 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
_fetchAndScrollToLatest() {
|
||||
this.loadedOnce = false;
|
||||
return this.fetchMessages({
|
||||
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
|
||||
// we now use this hack to disable it
|
||||
@bind
|
||||
_iOSFix(callback) {
|
||||
forceRendering(callback) {
|
||||
schedule("afterRender", () => {
|
||||
if (!this._scrollerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
this._scrollerEl.style.transform = "translateZ(0)";
|
||||
this._scrollerEl.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
|
@ -1253,8 +1197,11 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
this._scrollerEl.style.overflow = "auto";
|
||||
}, 25);
|
||||
this._scrollerEl.style.transform = "unset";
|
||||
this.computeDatesSeparators();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -1300,30 +1247,27 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
_throttleComputeSeparators() {
|
||||
throttle(this, this._computeSeparators, 32, false);
|
||||
}
|
||||
|
||||
_computeSeparators() {
|
||||
next(() => {
|
||||
@action
|
||||
computeDatesSeparators() {
|
||||
schedule("afterRender", () => {
|
||||
const dates = this._scrollerEl.querySelectorAll(
|
||||
".chat-message-separator-date"
|
||||
);
|
||||
const scrollHeight = document.querySelector(
|
||||
".chat-messages-scroll"
|
||||
).scrollHeight;
|
||||
const reversedDates = [...dates].reverse();
|
||||
// TODO (joffrey): optimize this code to trigger less layout computation
|
||||
reversedDates.forEach((date, index) => {
|
||||
const dates = [
|
||||
...this._scrollerEl.querySelectorAll(".chat-message-separator-date"),
|
||||
].reverse();
|
||||
const scrollHeight = this._scrollerEl.scrollHeight;
|
||||
|
||||
dates
|
||||
.map((date, index) => {
|
||||
const item = { bottom: "0px", date };
|
||||
if (index > 0) {
|
||||
date.style.bottom =
|
||||
scrollHeight - reversedDates[index - 1].offsetTop + "px";
|
||||
} else {
|
||||
date.style.bottom = 0;
|
||||
item.bottom = scrollHeight - dates[index - 1].offsetTop + "px";
|
||||
}
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,18 +2,30 @@
|
|||
{{#if this.hasUploads}}
|
||||
{{html-safe @cooked}}
|
||||
|
||||
<Collapser @header={{this.uploadsHeader}}>
|
||||
<Collapser
|
||||
@header={{this.uploadsHeader}}
|
||||
@onToggle={{@onToggleCollapse}}
|
||||
as |collapsed|
|
||||
>
|
||||
{{#unless collapsed}}
|
||||
<div class="chat-uploads">
|
||||
{{#each @uploads as |upload|}}
|
||||
<ChatUpload @upload={{upload}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
</Collapser>
|
||||
{{else}}
|
||||
{{#each this.cookedBodies as |cooked|}}
|
||||
{{#if cooked.needsCollapser}}
|
||||
<Collapser @header={{cooked.header}}>
|
||||
<Collapser
|
||||
@header={{cooked.header}}
|
||||
@onToggle={{@onToggleCollapse}}
|
||||
as |collapsed|
|
||||
>
|
||||
{{#unless collapsed}}
|
||||
{{cooked.body}}
|
||||
{{/unless}}
|
||||
</Collapser>
|
||||
{{else}}
|
||||
{{cooked.body}}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div
|
||||
class={{concat-class
|
||||
"chat-message-separator-date"
|
||||
(if @message.newest "last-visit")
|
||||
(if @message.newest "with-last-visit")
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
@ -10,11 +10,13 @@
|
|||
{{chat/track-message-separator-date}}
|
||||
>
|
||||
<span class="chat-message-separator__text">
|
||||
{{@message.firstMessageOfTheDayAt}}
|
||||
<span>{{@message.firstMessageOfTheDayAt}}</span>
|
||||
|
||||
{{#if @message.newest}}
|
||||
<span class="chat-message-separator__last-visit">
|
||||
-
|
||||
{{i18n "chat.last_visit"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<div class="chat-message-text">
|
||||
{{#if this.isCollapsible}}
|
||||
<ChatMessageCollapser @cooked={{@cooked}} @uploads={{@uploads}} />
|
||||
<ChatMessageCollapser
|
||||
@cooked={{@cooked}}
|
||||
@uploads={{@uploads}}
|
||||
@onToggleCollapse={{@onToggleCollapse}}
|
||||
/>
|
||||
{{else}}
|
||||
{{html-safe @cooked}}
|
||||
{{/if}}
|
||||
|
|
|
@ -117,6 +117,7 @@
|
|||
@cooked={{@message.cooked}}
|
||||
@uploads={{@message.uploads}}
|
||||
@edited={{@message.edited}}
|
||||
@onToggleCollapse={{fn @forceRendering (noop)}}
|
||||
>
|
||||
{{#if @message.reactions.length}}
|
||||
<div class="chat-message-reaction-list">
|
||||
|
|
|
@ -270,16 +270,27 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
|
||||
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 (
|
||||
!this.args.message?.chatWebhookEvent &&
|
||||
!this.args.message?.inReplyTo &&
|
||||
!this.args.message?.previousMessage?.deletedAt &&
|
||||
!message?.chatWebhookEvent &&
|
||||
(!message?.inReplyTo ||
|
||||
message?.inReplyTo?.user?.id !== message?.user?.id) &&
|
||||
!message?.previousMessage?.deletedAt &&
|
||||
Math.abs(
|
||||
new Date(this.args.message?.createdAt) -
|
||||
new Date(this.args.message?.createdAt)
|
||||
new Date(message?.createdAt) - new Date(previousMessage?.createdAt)
|
||||
) < 300000 && // If the time between messages is over 5 minutes, break.
|
||||
this.args.message?.user?.id ===
|
||||
this.args.message?.previousMessage?.user?.id
|
||||
message?.user?.id === message?.previousMessage?.user?.id
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -506,6 +517,8 @@ export default class ChatMessage extends Component {
|
|||
this.currentUser.id
|
||||
);
|
||||
|
||||
this.args.forceRendering?.();
|
||||
|
||||
return ajax(
|
||||
`/chat/${this.args.message.channelId}/react/${this.args.message.id}`,
|
||||
{
|
||||
|
|
|
@ -3,10 +3,7 @@
|
|||
class={{concat-class
|
||||
"btn-flat"
|
||||
"chat-scroll-to-bottom"
|
||||
(if
|
||||
(or (not @isAlmostDocked) @hasNewMessages @channel.canLoadMoreFuture)
|
||||
"visible"
|
||||
)
|
||||
(if (or @show @hasNewMessages) "visible")
|
||||
}}
|
||||
@action={{@scrollToBottom}}
|
||||
>
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
<div
|
||||
class="chat-skeleton -animation"
|
||||
{{did-insert @onInsert}}
|
||||
{{will-destroy @onDestroy}}
|
||||
>
|
||||
<div class="chat-skeleton -animation">
|
||||
{{#each this.placeholders as |placeholder|}}
|
||||
<div class="chat-skeleton__body">
|
||||
<div class="chat-skeleton__message">
|
||||
|
|
|
@ -16,6 +16,11 @@
|
|||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class={{if this.collapsed "hidden" ""}}>
|
||||
{{yield}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-collapser-body"
|
||||
(if this.collapsed "hidden")
|
||||
}}
|
||||
>
|
||||
{{yield this.collapsed}}
|
||||
</div>
|
|
@ -6,14 +6,17 @@ export default Component.extend({
|
|||
|
||||
collapsed: false,
|
||||
header: null,
|
||||
onToggle: null,
|
||||
|
||||
@action
|
||||
open() {
|
||||
this.set("collapsed", false);
|
||||
this.onToggle?.(false);
|
||||
},
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.set("collapsed", true);
|
||||
this.onToggle?.(true);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -47,11 +47,13 @@ export default class ChatMessage {
|
|||
@tracked availableFlags;
|
||||
@tracked newest = false;
|
||||
@tracked highlighted = false;
|
||||
@tracked firstOfResults = false;
|
||||
|
||||
constructor(channel, args = {}) {
|
||||
this.channel = channel;
|
||||
this.id = args.id;
|
||||
this.newest = args.newest;
|
||||
this.firstOfResults = args.firstOfResults;
|
||||
this.staged = args.staged;
|
||||
this.edited = args.edited;
|
||||
this.availableFlags = args.available_flags;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -243,7 +243,7 @@ export default class ChatApi extends Service {
|
|||
* @param {integer} data.pageSize - Max number of messages to fetch.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async messages(channelId, data = {}) {
|
||||
messages(channelId, data = {}) {
|
||||
let path;
|
||||
const args = {};
|
||||
|
||||
|
|
|
@ -134,6 +134,10 @@ export default class ChatChannelsManager extends Service {
|
|||
}
|
||||
|
||||
#cache(channel) {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._cached[channel.id] = channel;
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
&.last-visit {
|
||||
&.with-last-visit {
|
||||
& + .chat-message-separator__line-container {
|
||||
.chat-message-separator__line {
|
||||
border-color: var(--danger-medium);
|
||||
|
@ -57,11 +57,16 @@
|
|||
position: sticky;
|
||||
top: -1px;
|
||||
|
||||
&.is-pinned {
|
||||
&.is-pinned,
|
||||
&.is-force-pinned {
|
||||
.chat-message-separator__text {
|
||||
border: 1px solid var(--primary-medium);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-message-separator__last-visit {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -145,9 +145,13 @@ $float-height: 530px;
|
|||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
position: relative;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
|
||||
.chat-message-container {
|
||||
display: grid;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
|
||||
&.selecting-messages {
|
||||
grid-template-columns: 1.5em 1fr;
|
||||
|
@ -332,7 +336,7 @@ $float-height: 530px;
|
|||
position: absolute;
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
bottom: -75px;
|
||||
bottom: -25px;
|
||||
background: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.5s ease;
|
||||
|
@ -350,7 +354,7 @@ $float-height: 530px;
|
|||
}
|
||||
|
||||
&.visible {
|
||||
transform: translateY(-75px) scale(1);
|
||||
transform: translateY(-32px) scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
|
|
@ -52,7 +52,6 @@ RSpec.describe "Bookmark message", type: :system, js: true do
|
|||
context "when mobile", mobile: true do
|
||||
it "allows to bookmark a message" do
|
||||
chat.visit_channel(category_channel_1)
|
||||
expect(channel).to have_no_loading_skeleton
|
||||
|
||||
i = 0.5
|
||||
try_until_success(timeout: 20) do
|
||||
|
|
|
@ -35,7 +35,6 @@ RSpec.describe "Chat channel", type: :system, js: true do
|
|||
|
||||
it "allows to edit this message once persisted" do
|
||||
chat.visit_channel(channel_1)
|
||||
expect(channel).to have_no_loading_skeleton
|
||||
channel.send_message("aaaaaaaaaaaaaaaaaaaa")
|
||||
expect(page).to have_no_css(".chat-message-staged")
|
||||
last_message = find(".chat-message-container:last-child")
|
||||
|
|
|
@ -16,7 +16,6 @@ RSpec.describe "Deleted message", type: :system, js: true do
|
|||
context "when deleting a message" do
|
||||
it "shows as deleted" do
|
||||
chat_page.visit_channel(channel_1)
|
||||
expect(channel_page).to have_no_loading_skeleton
|
||||
channel_page.send_message("aaaaaaaaaaaaaaaaaaaa")
|
||||
expect(page).to have_no_css(".chat-message-staged")
|
||||
last_message = find(".chat-message-container:last-child")
|
||||
|
|
|
@ -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
|
||||
chat_page.visit_channel(channel1)
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
chat_channel_page.type_in_composer("this is #ra")
|
||||
expect(page).to have_css(
|
||||
".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
|
||||
chat_page.visit_channel(channel1)
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
chat_channel_page.type_in_composer(
|
||||
"this is #random and this is #raspberry-beret and this is #razed which is cool",
|
||||
)
|
||||
|
|
|
@ -21,7 +21,6 @@ RSpec.describe "Mentions warnings", type: :system, js: true do
|
|||
|
||||
it "displays a warning" do
|
||||
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} ")
|
||||
|
||||
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
|
||||
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} ")
|
||||
|
||||
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
|
||||
chat_page.visit_channel(channel_1)
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
chat_channel_page.type_in_composer(
|
||||
"@#{user_2.username} @#{publicly_mentionable_group.name} ",
|
||||
)
|
||||
|
|
|
@ -19,6 +19,7 @@ module PageObjects
|
|||
|
||||
def visit_channel(channel, mobile: false)
|
||||
visit(channel.url + (mobile ? "?mobile_view=1" : ""))
|
||||
has_no_css?(".not-loaded-once")
|
||||
has_no_css?(".chat-skeleton")
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -93,8 +93,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
it "quotes the message" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
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
|
||||
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])
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
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
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
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
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
click_selection_button("cancel")
|
||||
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
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
select_message_desktop(message_1)
|
||||
click_selection_button("quote")
|
||||
|
||||
|
@ -219,8 +209,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
mobile: true do
|
||||
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")
|
||||
click_selection_button("quote")
|
||||
|
||||
|
|
|
@ -57,6 +57,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
|
|||
onHoverMessage: () => {},
|
||||
didShowMessage: () => {},
|
||||
didHideMessage: () => {},
|
||||
forceRendering: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -75,6 +76,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
|
|||
@onHoverMessage={{this.onHoverMessage}}
|
||||
@didShowMessage={{this.didShowMessage}}
|
||||
@didHideMessage={{this.didHideMessage}}
|
||||
@didHideMessage={{this.didHideMessage}}
|
||||
@forceRendering={{this.forceRendering}}
|
||||
/>
|
||||
`;
|
||||
|
||||
|
|
Loading…
Reference in New Issue