UX: groups deleted messages (#21411)

Any continuous series of deleted messages will now be grouped into one single expand button.
This commit is contained in:
Joffrey JAFFEUX 2023-05-05 17:08:33 +02:00 committed by GitHub
parent ae3231e140
commit cb5e5f3e5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 325 additions and 215 deletions

View File

@ -108,7 +108,7 @@ export default class ChatLivePane extends Component {
// Technically we could keep messages to avoid re-fetching them, but // Technically we could keep messages to avoid re-fetching them, but
// it's not worth the complexity for now // it's not worth the complexity for now
this.args.channel?.messagesManager?.clearMessages(); this.args.channel?.clearMessages();
if (this._loadedChannelId !== this.args.channel?.id) { if (this._loadedChannelId !== this.args.channel?.id) {
this._unsubscribeToUpdates(this._loadedChannelId); this._unsubscribeToUpdates(this._loadedChannelId);
@ -183,7 +183,7 @@ export default class ChatLivePane extends Component {
results results
); );
this.args.channel.messages = messages; this.args.channel.addMessages(messages);
this.args.channel.details = meta; this.args.channel.details = meta;
if (this.requestedTargetMessageId) { if (this.requestedTargetMessageId) {
@ -224,8 +224,8 @@ export default class ChatLivePane extends Component {
const loadingMoreKey = `loadingMore${capitalize(direction)}`; const loadingMoreKey = `loadingMore${capitalize(direction)}`;
const canLoadMore = loadingPast const canLoadMore = loadingPast
? this.#messagesManager.canLoadMorePast ? this.args.channel?.canLoadMorePast
: this.#messagesManager.canLoadMoreFuture; : this.args.channel?.canLoadMoreFuture;
if ( if (
!canLoadMore || !canLoadMore ||
@ -276,7 +276,7 @@ export default class ChatLivePane extends Component {
} }
this.args.channel.details = meta; this.args.channel.details = meta;
this.#messagesManager.addMessages(messages); this.args.channel.addMessages(messages);
// Edge case for IOS to avoid blank screens // Edge case for IOS to avoid blank screens
// and/or scrolling to bottom losing track of scroll position // and/or scrolling to bottom losing track of scroll position
@ -367,7 +367,7 @@ export default class ChatLivePane extends Component {
@debounce(100) @debounce(100)
highlightOrFetchMessage(messageId) { highlightOrFetchMessage(messageId) {
const message = this.#messagesManager?.findMessage(messageId); const message = this.args.channel?.findMessage(messageId);
if (message) { if (message) {
this.scrollToMessage(message.id, { this.scrollToMessage(message.id, {
highlight: true, highlight: true,
@ -388,7 +388,7 @@ export default class ChatLivePane extends Component {
return; return;
} }
const message = this.#messagesManager?.findMessage(messageId); const message = this.args.channel?.findMessage(messageId);
if (message?.deletedAt && opts.autoExpand) { if (message?.deletedAt && opts.autoExpand) {
message.expanded = true; message.expanded = true;
} }
@ -485,7 +485,7 @@ export default class ChatLivePane extends Component {
return; return;
} }
if (this.#messagesManager?.canLoadMoreFuture) { if (this.args.channel?.canLoadMoreFuture) {
this._fetchAndScrollToLatest(); this._fetchAndScrollToLatest();
} else if (this.args.channel.messages?.length > 0) { } else if (this.args.channel.messages?.length > 0) {
this.scrollToMessage( this.scrollToMessage(
@ -534,9 +534,9 @@ export default class ChatLivePane extends Component {
} }
removeMessage(msgData) { removeMessage(msgData) {
const message = this.#messagesManager.findMessage(msgData.id); const message = this.args.channel?.findMessage(msgData.id);
if (message) { if (message) {
this.#messagesManager.removeMessage(message); this.args.channel?.removeMessage(message);
} }
} }
@ -557,7 +557,7 @@ export default class ChatLivePane extends Component {
if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { if (data.chat_message.user.id === this.currentUser.id && data.staged_id) {
const stagedMessage = handleStagedMessage( const stagedMessage = handleStagedMessage(
this.args.channel, this.args.channel,
this.#messagesManager, this.args.channel.messagesManager,
data data
); );
if (stagedMessage) { if (stagedMessage) {
@ -565,20 +565,19 @@ export default class ChatLivePane extends Component {
} }
} }
if (this.#messagesManager.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.#isTowardsBottom()) { } else if (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.#messagesManager.addMessages([message]);
this.scrollToLatestMessage(); this.scrollToLatestMessage();
this.updateLastReadMessage(); this.updateLastReadMessage();
} else { } else {
// If we are almost at the bottom, we append the message and notice the user // If we are almost at the bottom, we append the message and notice the user
const message = ChatMessage.create(this.args.channel, data.chat_message); const message = ChatMessage.create(this.args.channel, data.chat_message);
this.#messagesManager.addMessages([message]); this.args.channel.addMessages([message]);
this.hasNewMessages = true; this.hasNewMessages = true;
} }
} }
@ -589,10 +588,6 @@ export default class ChatLivePane extends Component {
return this.isDestroying || this.isDestroyed; return this.isDestroying || this.isDestroyed;
} }
get #messagesManager() {
return this.args.channel?.messagesManager;
}
@action @action
onSendMessage(message) { onSendMessage(message) {
if (message.editing) { if (message.editing) {
@ -711,7 +706,7 @@ export default class ChatLivePane extends Component {
} }
_onSendError(id, error) { _onSendError(id, error) {
const stagedMessage = this.#messagesManager.findStagedMessage(id); const stagedMessage = this.args.channel.findStagedMessage(id);
if (stagedMessage) { if (stagedMessage) {
if (error.jqXHR?.responseJSON?.errors?.length) { if (error.jqXHR?.responseJSON?.errors?.length) {
// only network errors are retryable // only network errors are retryable

View File

@ -1,5 +1,6 @@
{{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-invalid-interactive }}
{{#if this.shouldRender}}
{{#if (eq @context "channel")}} {{#if (eq @context "channel")}}
<ChatMessageSeparatorDate @message={{@message}} /> <ChatMessageSeparatorDate @message={{@message}} />
<ChatMessageSeparatorNew @message={{@message}} /> <ChatMessageSeparatorNew @message={{@message}} />
@ -45,7 +46,7 @@
<DButton <DButton
@class="btn-flat chat-message-expand" @class="btn-flat chat-message-expand"
@action={{this.expand}} @action={{this.expand}}
@label="chat.deleted" @translatedLabel={{this.deletedMessageLabel}}
/> />
</div> </div>
{{else if this.hiddenAndCollapsed}} {{else if this.hiddenAndCollapsed}}
@ -186,7 +187,9 @@
{{/if}} {{/if}}
{{#if this.mentionWarning.groups_with_too_many_members}} {{#if this.mentionWarning.groups_with_too_many_members}}
<p class="warning-item">{{this.groupsWithTooManyMembers}}</p> <p
class="warning-item"
>{{this.groupsWithTooManyMembers}}</p>
{{/if}} {{/if}}
{{/if}} {{/if}}
</div> </div>
@ -200,3 +203,4 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
</div> </div>
{{/if}}

View File

@ -69,9 +69,43 @@ export default class ChatMessage extends Component {
return !this.args.message?.expanded; return !this.args.message?.expanded;
} }
get deletedMessageLabel() {
let count = 1;
const recursiveCount = (message) => {
const previousMessage = message.previousMessage;
if (previousMessage?.deletedAt) {
count++;
recursiveCount(previousMessage);
}
};
recursiveCount(this.args.message);
return I18n.t("chat.deleted", { count });
}
get shouldRender() {
return (
this.args.message.expanded ||
!this.args.message.deletedAt ||
(this.args.message.deletedAt && !this.args.message.nextMessage?.deletedAt)
);
}
@action @action
expand() { expand() {
const recursiveExpand = (message) => {
const previousMessage = message.previousMessage;
if (previousMessage?.deletedAt) {
previousMessage.expanded = true;
recursiveExpand(previousMessage);
}
};
this.args.message.expanded = true; this.args.message.expanded = true;
recursiveExpand(this.args.message);
} }
@action @action

View File

@ -215,9 +215,9 @@ export default class ChatMessageInteractor {
bulkSelect(checked) { bulkSelect(checked) {
const channel = this.message.channel; const channel = this.message.channel;
const lastSelectedIndex = channel.findIndexOfMessage( const lastSelectedIndex = channel.findIndexOfMessage(
this.pane.lastSelectedMessage this.pane.lastSelectedMessage.id
); );
const newlySelectedIndex = channel.findIndexOfMessage(this.message); const newlySelectedIndex = channel.findIndexOfMessage(this.message.id);
const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort(
(a, b) => a - b (a, b) => a - b
); );

View File

@ -12,6 +12,7 @@ export default class ChatMessagesManager {
} }
clearMessages() { clearMessages() {
this.messages.forEach((message) => (message.manager = null));
this.messages.clear(); this.messages.clear();
this.canLoadMoreFuture = null; this.canLoadMoreFuture = null;
@ -19,6 +20,10 @@ export default class ChatMessagesManager {
} }
addMessages(messages = []) { addMessages(messages = []) {
messages.forEach((message) => {
message.manager = this;
});
this.messages = this.messages this.messages = this.messages
.concat(messages) .concat(messages)
.uniqBy("id") .uniqBy("id")
@ -40,4 +45,8 @@ export default class ChatMessagesManager {
(message) => message.staged && message.id === stagedMessageId (message) => message.staged && message.id === stagedMessageId
); );
} }
findIndexOfMessage(id) {
return this.messages.findIndex((m) => m.id === id);
}
} }

View File

@ -68,14 +68,30 @@ export default class ChatChannel extends RestModel {
threadsManager = new ChatThreadsManager(getOwner(this)); threadsManager = new ChatThreadsManager(getOwner(this));
messagesManager = new ChatMessagesManager(getOwner(this)); messagesManager = new ChatMessagesManager(getOwner(this));
findIndexOfMessage(message) { findIndexOfMessage(id) {
return this.messages.findIndex((m) => m.id === message.id); return this.messagesManager.findIndexOfMessage(id);
}
findStagedMessage(id) {
return this.messagesManager.findStagedMessage(id);
} }
findMessage(id) { findMessage(id) {
return this.messagesManager.findMessage(id); return this.messagesManager.findMessage(id);
} }
addMessages(messages) {
this.messagesManager.addMessages(messages);
}
clearMessages() {
this.messagesManager.clearMessages();
}
removeMessage(message) {
this.messagesManager.removeMessage(message);
}
get messages() { get messages() {
return this.messagesManager.messages; return this.messagesManager.messages;
} }
@ -88,6 +104,10 @@ export default class ChatChannel extends RestModel {
return this.messagesManager.canLoadMoreFuture; return this.messagesManager.canLoadMoreFuture;
} }
get canLoadMorePast() {
return this.messagesManager.canLoadMorePast;
}
get escapedTitle() { get escapedTitle() {
return escapeExpression(this.title); return escapeExpression(this.title);
} }
@ -186,10 +206,10 @@ export default class ChatChannel extends RestModel {
if (message.inReplyTo) { if (message.inReplyTo) {
if (!this.threadingEnabled) { if (!this.threadingEnabled) {
this.messagesManager.addMessages([message]); this.addMessages([message]);
} }
} else { } else {
this.messagesManager.addMessages([message]); this.addMessages([message]);
} }
} }

View File

@ -50,6 +50,8 @@ export default class ChatMessage {
@tracked message; @tracked message;
@tracked thread; @tracked thread;
@tracked threadReplyCount; @tracked threadReplyCount;
@tracked manager = null;
@tracked _cooked; @tracked _cooked;
constructor(channel, args = {}) { constructor(channel, args = {}) {
@ -193,17 +195,17 @@ export default class ChatMessage {
@cached @cached
get index() { get index() {
return this.channel.messages.indexOf(this); return this.manager?.messages?.indexOf(this);
} }
@cached @cached
get previousMessage() { get previousMessage() {
return this.channel?.messages?.objectAt?.(this.index - 1); return this.manager?.messages?.objectAt?.(this.index - 1);
} }
@cached @cached
get nextMessage() { get nextMessage() {
return this.channel?.messages?.objectAt?.(this.index + 1); return this.manager?.messages?.objectAt?.(this.index + 1);
} }
incrementVersion() { incrementVersion() {

View File

@ -7,6 +7,10 @@
color: var(--primary-low-mid); color: var(--primary-low-mid);
padding: 0.25em; padding: 0.25em;
.d-button-label {
text-align: left;
}
&:hover { &:hover {
background: inherit; background: inherit;
color: inherit; color: inherit;

View File

@ -81,7 +81,10 @@ en:
collapse: "Collapse Chat Drawer" collapse: "Collapse Chat Drawer"
expand: "Expand Chat Drawer" expand: "Expand Chat Drawer"
confirm_flag: "Are you sure you want to flag %{username}'s message?" confirm_flag: "Are you sure you want to flag %{username}'s message?"
deleted: "A message was deleted. [view]" deleted:
one: "A message was deleted. [view]"
other: "%{count} messages were deleted. [view all]"
hidden: "A message was hidden. [view]" hidden: "A message was hidden. [view]"
delete: "Delete" delete: "Delete"
edited: "edited" edited: "edited"

View File

@ -4,7 +4,7 @@ RSpec.describe "Deleted message", type: :system, js: true do
let(:chat_page) { PageObjects::Pages::Chat.new } let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new } let(:channel_page) { PageObjects::Pages::ChatChannel.new }
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:admin) }
fab!(:channel_1) { Fabricate(:category_channel) } fab!(:channel_1) { Fabricate(:category_channel) }
before do before do
@ -21,7 +21,33 @@ RSpec.describe "Deleted message", type: :system, js: true do
last_message = find(".chat-message-container:last-child") last_message = find(".chat-message-container:last-child")
channel_page.delete_message(OpenStruct.new(id: last_message["data-id"])) channel_page.delete_message(OpenStruct.new(id: last_message["data-id"]))
expect(page).to have_content(I18n.t("js.chat.deleted")) expect(channel_page).to have_deleted_message(
OpenStruct.new(id: last_message["data-id"]),
count: 1,
)
end
end
context "when deleting multiple messages" do
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_3) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_4) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_5) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:message_6) { Fabricate(:chat_message, chat_channel: channel_1) }
it "groups them" do
chat_page.visit_channel(channel_1)
channel_page.delete_message(message_1)
channel_page.delete_message(message_3)
channel_page.delete_message(message_4)
channel_page.delete_message(message_6)
expect(channel_page).to have_deleted_message(message_1)
expect(channel_page).to have_deleted_message(message_4, count: 2)
expect(channel_page).to have_deleted_message(message_6)
expect(channel_page).to have_no_message(id: message_3.id)
end end
end end
@ -65,9 +91,9 @@ RSpec.describe "Deleted message", type: :system, js: true do
) )
expect(channel_page).to have_no_message(id: message_1.id) expect(channel_page).to have_no_message(id: message_1.id)
expect(channel_page).to have_no_message(id: message_2.id) expect(channel_page).to have_deleted_message(message_2, count: 2)
expect(open_thread).to have_no_message(thread_id: thread.id, id: message_4.id) expect(open_thread).to have_no_message(thread_id: thread.id, id: message_4.id)
expect(open_thread).to have_no_message(thread_id: thread.id, id: message_5.id) expect(open_thread).to have_deleted_message(message_5, count: 2)
end end
end end
end end

View File

@ -87,8 +87,7 @@ RSpec.describe "Move message to channel", type: :system, js: true do
chat.visit_channel(channel_1) chat.visit_channel(channel_1)
expect(page).to have_no_content(message_1.message) expect(channel).to have_deleted_message(message_1)
expect(page).to have_content(I18n.t("js.chat.deleted"))
end end
end end
end end

View File

@ -172,6 +172,13 @@ module PageObjects
check_message_presence(exists: false, text: text, id: id) check_message_presence(exists: false, text: text, id: id)
end end
def has_deleted_message?(message, count: 1)
has_css?(
".chat-channel .chat-message-container[data-id=\"#{message.id}\"] .chat-message-deleted",
text: I18n.t("js.chat.deleted", count: count),
)
end
def check_message_presence(exists: true, text: nil, id: nil) def check_message_presence(exists: true, text: nil, id: nil)
css_method = exists ? :has_css? : :has_no_css? css_method = exists ? :has_css? : :has_no_css?
if text if text

View File

@ -77,6 +77,13 @@ module PageObjects
def message_by_id_selector(id) def message_by_id_selector(id)
".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]" ".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
end end
def has_deleted_message?(message, count: 1)
has_css?(
".chat-thread .chat-message-container[data-id=\"#{message.id}\"] .chat-message-deleted",
text: I18n.t("js.chat.deleted", count: count),
)
end
end end
end end
end end