FEATURE: chat redesign - back button to exit threads (#24189)

Chat redesign work to improve chat navigation:

- New header title with channel name (thread list on mobile)
- New header title without channel name (thread list on full page chat)
- Removes the close button on threads (mobile only)
- Updates to back button route within thread (mobile), taking user to:
    - The thread index, if they accessed the thread from the thread index.
    - The channel itself, if they accessed the thread directly from the channel.
    - The channel itself, if they accessed the thread from a notification.
- Show thread title in chat drawer header
- Properly convert emoji in thread titles in chat header (all devices)
- Upgrades various templates to use gjs format.
This commit is contained in:
David Battersby 2023-11-07 16:01:09 +08:00 committed by GitHub
parent b90b7ac705
commit f20b6a0cc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 468 additions and 298 deletions

View File

@ -0,0 +1,40 @@
import Component from "@glimmer/component";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
export default class ChatDrawerHeaderTitle extends Component {
get headerTitle() {
if (this.args.title) {
return I18n.t(this.args.title);
}
return replaceEmoji(this.args.translatedTitle);
}
get showChannel() {
return this.args.channelName ?? false;
}
get showIcon() {
return this.args.icon ?? false;
}
<template>
<span class="chat-drawer-header__title">
<div class="chat-drawer-header__top-line">
<span class="chat-drawer-header__icon">
{{#if this.showIcon}}
{{icon @icon}}
{{/if}}
</span>
<span class="chat-drawer-header__title-text">{{this.headerTitle}}</span>
{{#if this.showChannel}}
<span class="chat-drawer-header__divider">-</span>
<span class="chat-drawer-header__channel-name">{{@channelName}}</span>
{{/if}}
</div>
</span>
</template>
}

View File

@ -1,5 +0,0 @@
<span class="chat-drawer-header__title">
<div class="chat-drawer-header__top-line">
{{i18n @title}}
</div>
</span>

View File

@ -0,0 +1,100 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { inject as service } from "@ember/service";
import I18n from "discourse-i18n";
import and from "truth-helpers/helpers/and";
import ChatDrawerHeader from "discourse/plugins/chat/discourse/components/chat-drawer/header";
import ChatDrawerHeaderBackLink from "discourse/plugins/chat/discourse/components/chat-drawer/header/back-link";
import ChatDrawerHeaderRightActions from "discourse/plugins/chat/discourse/components/chat-drawer/header/right-actions";
import ChatDrawerHeaderTitle from "discourse/plugins/chat/discourse/components/chat-drawer/header/title";
import ChatThread from "discourse/plugins/chat/discourse/components/chat-thread";
export default class ChatDrawerThread extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
@service chatHistory;
get backLink() {
const link = {
models: this.chat.activeChannel.routeModels,
};
if (this.chatHistory.previousRoute?.name === "chat.channel.threads") {
link.title = I18n.t("chat.return_to_threads_list");
link.route = "chat.channel.threads";
} else {
link.title = I18n.t("chat.return_to_channel");
link.route = "chat.channel";
}
return link;
}
get threadTitle() {
return (
this.chat.activeChannel?.activeThread?.title ??
I18n.t("chat.thread.label")
);
}
@action
fetchChannelAndThread() {
if (!this.args.params?.channelId || !this.args.params?.threadId) {
return;
}
return this.chatChannelsManager
.find(this.args.params.channelId)
.then((channel) => {
this.chat.activeChannel = channel;
channel.threadsManager
.find(channel.id, this.args.params.threadId)
.then((thread) => {
this.chat.activeChannel.activeThread = thread;
});
});
}
<template>
<ChatDrawerHeader @toggleExpand={{@drawerActions.toggleExpand}}>
{{#if
(and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)
}}
<div class="chat-drawer-header__left-actions">
<div class="chat-drawer-header__top-line">
<ChatDrawerHeaderBackLink
@route={{this.backLink.route}}
@title={{this.backLink.title}}
@routeModels={{this.backLink.models}}
/>
</div>
</div>
{{/if}}
<ChatDrawerHeaderTitle @translatedTitle={{this.threadTitle}} />
<ChatDrawerHeaderRightActions @drawerActions={{@drawerActions}} />
</ChatDrawerHeader>
{{#if this.chatStateManager.isDrawerExpanded}}
<div
class="chat-drawer-content"
{{didInsert this.fetchChannelAndThread}}
{{didUpdate this.fetchChannelAndThread @params.channelId}}
{{didUpdate this.fetchChannelAndThread @params.threadId}}
>
{{#if this.chat.activeChannel.activeThread}}
<ChatThread
@thread={{this.chat.activeChannel.activeThread}}
@targetMessageId={{@params.messageId}}
/>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -1,33 +0,0 @@
<ChatDrawer::Header @toggleExpand={{@drawerActions.toggleExpand}}>
{{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}}
<div class="chat-drawer-header__left-actions">
<div class="chat-drawer-header__top-line">
<ChatDrawer::Header::BackLink
@route={{this.backLink.route}}
@title={{i18n this.backLink.title}}
@routeModels={{this.backLink.models}}
/>
</div>
</div>
{{/if}}
<ChatDrawer::Header::Title @title="chat.thread.label" />
<ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} />
</ChatDrawer::Header>
{{#if this.chatStateManager.isDrawerExpanded}}
<div
class="chat-drawer-content"
{{did-insert this.fetchChannelAndThread}}
{{did-update this.fetchChannelAndThread @params.channelId}}
{{did-update this.fetchChannelAndThread @params.threadId}}
>
{{#if this.chat.activeChannel.activeThread}}
<ChatThread
@thread={{this.chat.activeChannel.activeThread}}
@targetMessageId={{@params.messageId}}
/>
{{/if}}
</div>
{{/if}}

View File

@ -1,46 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatDrawerThread extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
@service chatHistory;
get backLink() {
const link = {
models: this.chat.activeChannel.routeModels,
};
if (this.chatHistory.previousRoute?.name === "chat.channel.threads") {
link.title = "chat.return_to_threads_list";
link.route = "chat.channel.threads";
} else {
link.title = "chat.return_to_list";
link.route = "chat.channel";
}
return link;
}
@action
fetchChannelAndThread() {
if (!this.args.params?.channelId || !this.args.params?.threadId) {
return;
}
return this.chatChannelsManager
.find(this.args.params.channelId)
.then((channel) => {
this.chat.activeChannel = channel;
channel.threadsManager
.find(channel.id, this.args.params.threadId)
.then((thread) => {
this.chat.activeChannel.activeThread = thread;
});
});
}
}

View File

@ -0,0 +1,70 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { inject as service } from "@ember/service";
import I18n from "discourse-i18n";
import and from "truth-helpers/helpers/and";
import ChatDrawerHeader from "discourse/plugins/chat/discourse/components/chat-drawer/header";
import ChatDrawerHeaderBackLink from "discourse/plugins/chat/discourse/components/chat-drawer/header/back-link";
import ChatDrawerHeaderRightActions from "discourse/plugins/chat/discourse/components/chat-drawer/header/right-actions";
import ChatDrawerHeaderTitle from "discourse/plugins/chat/discourse/components/chat-drawer/header/title";
import ChatThreadList from "discourse/plugins/chat/discourse/components/chat-thread-list";
export default class ChatDrawerThreads extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
backLinkTitle = I18n.t("chat.return_to_list");
@action
fetchChannel() {
if (!this.args.params?.channelId) {
return;
}
return this.chatChannelsManager
.find(this.args.params.channelId)
.then((channel) => {
this.chat.activeChannel = channel;
});
}
<template>
<ChatDrawerHeader @toggleExpand={{@drawerActions.toggleExpand}}>
{{#if
(and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)
}}
<div class="chat-drawer-header__left-actions">
<div class="chat-drawer-header__top-line">
<ChatDrawerHeaderBackLink
@route="chat.channel"
@title={{this.backLinkTitle}}
@routeModels={{this.chat.activeChannel.routeModels}}
/>
</div>
</div>
{{/if}}
<ChatDrawerHeaderTitle
@title="chat.threads.list"
@icon="discourse-threads"
@channelName={{this.chat.activeChannel.title}}
/>
<ChatDrawerHeaderRightActions @drawerActions={{@drawerActions}} />
</ChatDrawerHeader>
{{#if this.chatStateManager.isDrawerExpanded}}
<div class="chat-drawer-content" {{didInsert this.fetchChannel}}>
{{#if this.chat.activeChannel}}
<ChatThreadList
@channel={{this.chat.activeChannel}}
@includeHeader={{false}}
/>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -1,28 +0,0 @@
<ChatDrawer::Header @toggleExpand={{@drawerActions.toggleExpand}}>
{{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}}
<div class="chat-drawer-header__left-actions">
<div class="chat-drawer-header__top-line">
<ChatDrawer::Header::BackLink
@route="chat.channel"
@title={{i18n "chat.return_to_list"}}
@routeModels={{this.chat.activeChannel.routeModels}}
/>
</div>
</div>
{{/if}}
<ChatDrawer::Header::Title @title="chat.threads.list" />
<ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} />
</ChatDrawer::Header>
{{#if this.chatStateManager.isDrawerExpanded}}
<div class="chat-drawer-content" {{did-insert this.fetchChannel}}>
{{#if this.chat.activeChannel}}
<ChatThreadList
@channel={{this.chat.activeChannel}}
@includeHeader={{false}}
/>
{{/if}}
</div>
{{/if}}

View File

@ -1,23 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatDrawerThreads extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
@action
fetchChannel() {
if (!this.args.params?.channelId) {
return;
}
return this.chatChannelsManager
.find(this.args.params.channelId)
.then((channel) => {
this.chat.activeChannel = channel;
});
}
}

View File

@ -0,0 +1,70 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
export default class ChatThreadListHeader extends Component {
@service router;
@service site;
threadListTitle = I18n.t("chat.threads.list");
closeButtonTitle = I18n.t("chat.thread.close");
showCloseButton = !this.site.mobileView;
get showBackButton() {
return this.args.channel && this.site.mobileView;
}
get backButton() {
return {
route: "chat.channel.index",
models: this.args.channel.routeModels,
title: I18n.t("chat.return_to_channel"),
};
}
<template>
<div class="chat-thread-list-header">
<div class="chat-thread-header__left-buttons">
{{#if this.showBackButton}}
<LinkTo
class="chat-thread__back-to-previous-route btn-flat btn btn-icon no-text"
@route={{this.backButton.route}}
@models={{this.backButton.models}}
title={{this.backButton.title}}
>
{{icon "chevron-left"}}
</LinkTo>
{{/if}}
</div>
<div class="chat-thread-list-header__label">
<span>
{{icon "discourse-threads"}}
{{replaceEmoji this.threadListTitle}}
</span>
{{#if this.site.mobileView}}
<div class="chat-thread-list-header__label-channel">
{{replaceEmoji @channel.title}}
</div>
{{/if}}
</div>
{{#if this.showCloseButton}}
<div class="chat-thread-header__buttons">
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@channel.routeModels}}
title={{this.closeButtonTitle}}
>
{{icon "times"}}
</LinkTo>
</div>
{{/if}}
</div>
</template>
}

View File

@ -1,16 +0,0 @@
<div class="chat-thread-header">
<span class="chat-thread-header__label">
{{replace-emoji (i18n "chat.threads.list")}}
</span>
<div class="chat-thread-header__buttons">
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@channel.routeModels}}
title={{i18n "chat.thread.close"}}
>
{{d-icon "times"}}
</LinkTo>
</div>
</div>

View File

@ -0,0 +1,166 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import replaceEmoji from "discourse/helpers/replace-emoji";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { NotificationLevels } from "discourse/lib/notification-levels";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import ChatModalThreadSettings from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings";
import ChatThreadHeaderUnreadIndicator from "discourse/plugins/chat/discourse/components/chat/thread/header-unread-indicator";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
import ThreadNotificationsButton from "discourse/plugins/chat/select-kit/addons/components/thread-notifications-button";
export default class ChatThreadHeader extends Component {
@service currentUser;
@service chatApi;
@service router;
@service chatStateManager;
@service chatHistory;
@service site;
@service modal;
@tracked persistedNotificationLevel = true;
closeThreadTitle = I18n.t("chat.thread.close");
get backLink() {
const prevPage = this.chatHistory.previousRoute?.name;
let route, title;
if (prevPage === "chat.channel.threads") {
route = "chat.channel.threads";
title = I18n.t("chat.return_to_threads_list");
} else if (prevPage === "chat.channel.index" && !this.site.mobileView) {
route = "chat.channel.threads";
title = I18n.t("chat.return_to_threads_list");
} else {
route = "chat.channel.index";
title = I18n.t("chat.return_to_channel");
}
return {
route,
models: this.args.channel.routeModels,
title,
};
}
get canChangeThreadSettings() {
if (!this.args.thread) {
return false;
}
return (
this.currentUser.staff ||
this.currentUser.id === this.args.thread.originalMessage.user.id
);
}
get threadNotificationLevel() {
return this.membership?.notificationLevel || NotificationLevels.REGULAR;
}
get membership() {
return this.args.thread.currentUserMembership;
}
get headerTitle() {
return this.args.thread?.title ?? I18n.t("chat.thread.label");
}
@action
openThreadSettings() {
this.modal.show(ChatModalThreadSettings, { model: this.args.thread });
}
@action
updateThreadNotificationLevel(newNotificationLevel) {
this.persistedNotificationLevel = false;
let currentNotificationLevel;
if (this.membership) {
currentNotificationLevel = this.membership.notificationLevel;
this.membership.notificationLevel = newNotificationLevel;
} else {
this.args.thread.currentUserMembership = UserChatThreadMembership.create({
notification_level: newNotificationLevel,
last_read_message_id: null,
});
}
return this.chatApi
.updateCurrentUserThreadNotificationsSettings(
this.args.thread.channel.id,
this.args.thread.id,
{ notificationLevel: newNotificationLevel }
)
.then((response) => {
this.membership.last_read_message_id =
response.membership.last_read_message_id;
this.persistedNotificationLevel = true;
})
.catch((err) => {
this.membership.notificationLevel = currentNotificationLevel;
popupAjaxError(err);
});
}
<template>
<div class="chat-thread-header">
<div class="chat-thread-header__left-buttons">
{{#if @thread}}
<LinkTo
class="chat-thread__back-to-previous-route btn-flat btn btn-icon no-text"
@route={{this.backLink.route}}
@models={{this.backLink.models}}
title={{this.backLink.title}}
>
<ChatThreadHeaderUnreadIndicator @channel={{@thread.channel}} />
{{icon "chevron-left"}}
</LinkTo>
{{/if}}
</div>
<span class="chat-thread-header__label overflow-ellipsis">
{{replaceEmoji this.headerTitle}}
</span>
<div
class={{concatClass
"chat-thread-header__buttons"
(if this.persistedNotificationLevel "-persisted")
}}
>
<ThreadNotificationsButton
@value={{this.threadNotificationLevel}}
@onChange={{this.updateThreadNotificationLevel}}
/>
{{#if this.canChangeThreadSettings}}
<DButton
@action={{this.openThreadSettings}}
@icon="cog"
@title="chat.thread.settings"
class="btn-flat chat-thread-header__settings"
/>
{{/if}}
{{#unless this.site.mobileView}}
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@thread.channel.routeModels}}
title={{this.closeThreadTitle}}
>
{{icon "times"}}
</LinkTo>
{{/unless}}
</div>
</div>
</template>
}

View File

@ -1,47 +0,0 @@
<div class="chat-thread-header">
<div class="chat-thread-header__left-buttons">
{{#if @thread}}
<LinkTo
class="chat-thread__back-to-previous-route btn-flat btn btn-icon no-text"
@route={{this.backLink.route}}
@models={{this.backLink.models}}
title={{i18n "chat.return_to_threads_list"}}
>
<Chat::Thread::HeaderUnreadIndicator @channel={{@thread.channel}} />
{{d-icon "chevron-left"}}
</LinkTo>
{{/if}}
</div>
<span class="chat-thread-header__label overflow-ellipsis">
{{replace-emoji @thread.title}}
</span>
<div
class={{concat-class
"chat-thread-header__buttons"
(if this.persistedNotificationLevel "-persisted")
}}
>
<ThreadNotificationsButton
@value={{this.threadNotificationLevel}}
@onChange={{this.updateThreadNotificationLevel}}
/>
{{#if this.canChangeThreadSettings}}
<DButton
@action={{this.openThreadSettings}}
@icon="cog"
@title="chat.thread.settings"
class="btn-flat chat-thread-header__settings"
/>
{{/if}}
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@thread.channel.routeModels}}
title={{i18n "chat.thread.close"}}
>
{{d-icon "times"}}
</LinkTo>
</div>
</div>

View File

@ -1,96 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { NotificationLevels } from "discourse/lib/notification-levels";
import ChatModalThreadSettings from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
export default class ChatThreadHeader extends Component {
@service currentUser;
@service chatApi;
@service router;
@service chatStateManager;
@service chatHistory;
@service site;
@service modal;
@tracked persistedNotificationLevel = true;
get backLink() {
let route;
if (
this.chatHistory.previousRoute?.name === "chat.channel.index" &&
this.site.mobileView
) {
route = "chat.channel.index";
} else {
route = "chat.channel.threads";
}
return {
route,
models: this.args.channel.routeModels,
};
}
get canChangeThreadSettings() {
if (!this.args.thread) {
return false;
}
return (
this.currentUser.staff ||
this.currentUser.id === this.args.thread.originalMessage.user.id
);
}
get threadNotificationLevel() {
return this.membership?.notificationLevel || NotificationLevels.REGULAR;
}
get membership() {
return this.args.thread.currentUserMembership;
}
@action
openThreadSettings() {
this.modal.show(ChatModalThreadSettings, { model: this.args.thread });
}
@action
updateThreadNotificationLevel(newNotificationLevel) {
this.persistedNotificationLevel = false;
let currentNotificationLevel;
if (this.membership) {
currentNotificationLevel = this.membership.notificationLevel;
this.membership.notificationLevel = newNotificationLevel;
} else {
this.args.thread.currentUserMembership = UserChatThreadMembership.create({
notification_level: newNotificationLevel,
last_read_message_id: null,
});
}
this.chatApi
.updateCurrentUserThreadNotificationsSettings(
this.args.thread.channel.id,
this.args.thread.id,
{ notificationLevel: newNotificationLevel }
)
.then((response) => {
this.membership.last_read_message_id =
response.membership.last_read_message_id;
this.persistedNotificationLevel = true;
})
.catch((err) => {
this.membership.notificationLevel = currentNotificationLevel;
popupAjaxError(err);
});
}
}

View File

@ -148,6 +148,14 @@ a.chat-drawer-header__title {
}
}
.chat-drawer-header__icon {
margin-right: 0.25rem;
}
.chat-drawer-header__divider {
margin: 0 0.25rem;
}
.chat-drawer-header {
box-sizing: border-box;
border-bottom: solid 1px var(--primary-low);

View File

@ -12,4 +12,13 @@
display: flex;
margin-left: auto;
}
&__label span {
font-weight: bold;
}
&__label-channel {
display: block;
font-size: var(--font-down-1-rem);
}
}

View File

@ -259,7 +259,8 @@ en:
save: "Save"
select: "Select"
return_to_list: "Return to channels list"
return_to_threads_list: "Return to ongoing discussions"
return_to_channel: "Return to channel"
return_to_threads_list: "Return to threads list"
unread_threads_count:
one: "You have %{count} unread discussion"
other: "You have %{count} unread discussions"
@ -600,7 +601,7 @@ en:
other: "+%{count}"
threads:
open: "Open Thread"
list: "Ongoing discussions"
list: "Threads"
none: "You are not participating in any threads in this channel."
draft_channel_screen:

View File

@ -35,7 +35,7 @@ RSpec.describe "Reply to message - channel - mobile", type: :system, mobile: tru
expect(thread_page.messages).to have_message(text: text, persisted: true)
thread_page.close
thread_page.back_to_previous_route
expect(channel_page).to have_thread_indicator(original_message)
end
@ -69,7 +69,7 @@ RSpec.describe "Reply to message - channel - mobile", type: :system, mobile: tru
expect(thread_page.messages).to have_message(text: message_1.message)
expect(thread_page.messages).to have_message(text: "reply to message")
thread_page.close
thread_page.back_to_previous_route
expect(channel_page.message_thread_indicator(original_message)).to have_reply_count(2)
expect(channel_page.messages).to have_no_message(text: "reply to message")