FEATURE: Mobile Chat Footer Redesign (#25161)

This update adds three tabs to the bottom of the chat overlay to make it easier for users to navigate chat on mobile.

As a result of this change:

- Direct Messages are now shown separately from public channels on mobile
- My Threads has now moved from the channel list to it's own tab on mobile
- My Threads can still be accessed on desktop via the sidebar and within the drawer channel list
- Chat back button has been updated to navigate to the correct tab (for both channels and threads)

Some special cases:

- If DMs are not used then the tab is not rendered
- If the user has no threads then the tab is not rendered
- If both the tabs for DMs and Threads aren't available then the whole footer will not be rendered
- Chat footer is only shown on the listing pages (DMs, Channels, My Threads)

---------

Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com>
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
David Battersby 2024-01-16 14:29:33 +08:00 committed by GitHub
parent 1e57fed3b9
commit 4512e5652f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 801 additions and 345 deletions

View File

@ -21,4 +21,11 @@ class Chat::Api::CurrentUserThreadsController < Chat::ApiController
on_model_not_found(:threads) { render json: success_json.merge(threads: []) }
end
end
def thread_count
with_service(::Chat::LookupUserThreads) do
on_success { render json: success_json.merge(thread_count: result.threads.size) }
on_model_not_found(:threads) { render json: success_json.merge(thread_count: 0) }
end
end
end

View File

@ -8,6 +8,8 @@ export default function () {
});
});
this.route("direct-messages", { path: "/direct-messages" });
this.route("channels", { path: "/channels" });
this.route("threads", { path: "/threads" });
this.route(

View File

@ -0,0 +1,133 @@
import Component from "@glimmer/component";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
import ChatChannelRow from "./chat-channel-row";
export default class ChannelsListDirect extends Component {
@service chat;
@service chatChannelsManager;
@service site;
@service modal;
@action
openNewMessageModal() {
this.modal.show(ChatModalNewMessage);
}
get inSidebar() {
return this.args.inSidebar ?? false;
}
get createDirectMessageChannelLabel() {
if (!this.canCreateDirectMessageChannel) {
return "chat.direct_messages.cannot_create";
}
return "chat.direct_messages.new";
}
get showDirectMessageChannels() {
return (
this.canCreateDirectMessageChannel || !this.directMessageChannelsEmpty
);
}
get canCreateDirectMessageChannel() {
return this.chat.userCanDirectMessage;
}
get directMessageChannelClasses() {
return `channels-list-container direct-message-channels ${
this.inSidebar ? "collapsible-sidebar-section" : ""
}`;
}
get directMessageChannelsEmpty() {
return this.chatChannelsManager.directMessageChannels?.length === 0;
}
@action
toggleChannelSection(section) {
this.args.toggleSection(section);
}
<template>
<div
role="region"
aria-label={{i18n "chat.aria_roles.channels_list"}}
class="channels-list"
>
<PluginOutlet
@name="below-direct-chat-channels"
@tagName=""
@outletArgs={{hash inSidebar=this.inSidebar}}
/>
{{#if this.showDirectMessageChannels}}
{{#if this.site.desktopView}}
<div class="chat-channel-divider direct-message-channels-section">
{{#if this.inSidebar}}
<span
class="title-caret"
id="direct-message-channels-caret"
role="button"
title="toggle nav list"
{{on
"click"
(fn this.toggleChannelSection "direct-message-channels")
}}
data-toggleable="direct-message-channels"
>
{{dIcon "angle-up"}}
</span>
{{/if}}
<span class="channel-title">{{i18n
"chat.direct_messages.title"
}}</span>
{{#if this.canCreateDirectMessageChannel}}
<DButton
@icon="plus"
class="no-text btn-flat open-new-message-btn"
@action={{this.openNewMessageModal}}
title={{i18n this.createDirectMessageChannelLabel}}
/>
{{/if}}
</div>
{{/if}}
{{/if}}
<div
id="direct-message-channels"
class={{this.directMessageChannelClasses}}
>
{{#if this.directMessageChannelsEmpty}}
<div class="channel-list-empty-message">
<span class="channel-title">{{i18n
"chat.no_direct_message_channels"
}}</span>
</div>
{{else}}
{{#each
this.chatChannelsManager.truncatedDirectMessageChannels
as |channel|
}}
<ChatChannelRow
@channel={{channel}}
@options={{hash leaveButton=true}}
/>
{{/each}}
{{/if}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,141 @@
import Component from "@glimmer/component";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import ChatChannelRow from "./chat-channel-row";
export default class ChannelsListPublic extends Component {
@service chatChannelsManager;
@service site;
@service siteSettings;
@service currentUser;
get inSidebar() {
return this.args.inSidebar ?? false;
}
get publicMessageChannelsEmpty() {
return this.chatChannelsManager.publicMessageChannels?.length === 0;
}
get displayPublicChannels() {
if (!this.siteSettings.enable_public_channels) {
return false;
}
if (this.publicMessageChannelsEmpty) {
return (
this.currentUser?.staff ||
this.currentUser?.has_joinable_public_channels
);
}
return true;
}
get hasUnreadThreads() {
return this.chatChannelsManager.publicMessageChannels.some(
(channel) => channel.unreadThreadsCount > 0
);
}
@action
toggleChannelSection(section) {
this.args.toggleSection(section);
}
<template>
<div
role="region"
aria-label={{i18n "chat.aria_roles.channels_list"}}
class="channels-list"
>
{{#if this.site.desktopView}}
<LinkTo @route="chat.threads" class="chat-channel-row --threads">
<span class="chat-channel-title">
{{dIcon "discourse-threads" class="chat-user-threads__icon"}}
{{i18n "chat.my_threads.title"}}
</span>
{{#if this.hasUnreadThreads}}
<div class="c-unread-indicator">
<div class="c-unread-indicator__number">&nbsp;</div>
</div>
{{/if}}
</LinkTo>
{{/if}}
{{#if this.displayPublicChannels}}
{{#if this.site.desktopView}}
<div class="chat-channel-divider public-channels-section">
{{#if this.inSidebar}}
<span
class="title-caret"
id="public-channels-caret"
role="button"
title="toggle nav list"
{{on "click" (fn this.toggleChannelSection "public-channels")}}
data-toggleable="public-channels"
>
{{dIcon "angle-up"}}
</span>
{{/if}}
<span class="channel-title">{{i18n "chat.chat_channels"}}</span>
<LinkTo
@route="chat.browse"
class="btn no-text btn-flat open-browse-page-btn title-action"
title={{i18n "chat.channels_list_popup.browse"}}
>
{{dIcon "pencil-alt"}}
</LinkTo>
</div>
{{/if}}
<div
id="public-channels"
class={{concatClass
"channels-list-container"
"public-channels"
(if this.inSidebar "collapsible-sidebar-section")
}}
>
{{#if this.publicMessageChannelsEmpty}}
<div class="channel-list-empty-message">
<span class="channel-title">{{i18n
"chat.no_public_channels"
}}</span>
<LinkTo @route="chat.browse">
{{i18n "chat.click_to_join"}}
</LinkTo>
</div>
{{else}}
{{#each
this.chatChannelsManager.publicMessageChannels
as |channel|
}}
<ChatChannelRow
@channel={{channel}}
@options={{hash settingsButton=true}}
/>
{{/each}}
{{/if}}
</div>
{{/if}}
<PluginOutlet
@name="below-public-chat-channels"
@tagName=""
@outletArgs={{hash inSidebar=this.inSidebar}}
/>
</div>
</template>
}

View File

@ -1,298 +1,16 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { LinkTo } from "@ember/routing";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import noop from "discourse/helpers/noop";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import and from "truth-helpers/helpers/and";
import not from "truth-helpers/helpers/not";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
import onResize from "../modifiers/chat/on-resize";
import ChatChannelRow from "./chat-channel-row";
import ChannelsListDirect from "discourse/plugins/chat/discourse/components/channels-list-direct";
import ChannelsListPublic from "discourse/plugins/chat/discourse/components/channels-list-public";
export default class ChannelsList extends Component {
@service chat;
@service router;
@service chatStateManager;
@service chatChannelsManager;
@service site;
@service siteSettings;
@service session;
@service currentUser;
@service modal;
@tracked hasScrollbar = false;
@action
computeHasScrollbar(element) {
this.hasScrollbar = element.scrollHeight > element.clientHeight;
}
@action
computeResizedEntries(entries) {
this.computeHasScrollbar(entries[0].target);
}
@action
openNewMessageModal() {
this.modal.show(ChatModalNewMessage);
}
get showMobileDirectMessageButton() {
return this.site.mobileView && this.canCreateDirectMessageChannel;
}
get inSidebar() {
return this.args.inSidebar ?? false;
}
get publicMessageChannelsEmpty() {
return this.chatChannelsManager.publicMessageChannels?.length === 0;
}
get createDirectMessageChannelLabel() {
if (!this.canCreateDirectMessageChannel) {
return "chat.direct_messages.cannot_create";
}
return "chat.direct_messages.new";
}
get showDirectMessageChannels() {
return (
this.canCreateDirectMessageChannel ||
this.chatChannelsManager.directMessageChannels?.length > 0
);
}
get canCreateDirectMessageChannel() {
return this.chat.userCanDirectMessage;
}
get displayPublicChannels() {
if (!this.siteSettings.enable_public_channels) {
return false;
}
if (this.publicMessageChannelsEmpty) {
return (
this.currentUser?.staff ||
this.currentUser?.has_joinable_public_channels
);
}
return true;
}
get directMessageChannelClasses() {
return `channels-list-container direct-message-channels ${
this.inSidebar ? "collapsible-sidebar-section" : ""
}`;
}
get hasUnreadThreads() {
return this.chatChannelsManager.publicMessageChannels.some(
(channel) => channel.unreadThreadsCount > 0
);
}
@action
toggleChannelSection(section) {
this.args.toggleSection(section);
}
didRender() {
super.didRender(...arguments);
schedule("afterRender", this._applyScrollPosition);
}
@action
storeScrollPosition() {
if (this.chatStateManager.isDrawerActive) {
return;
}
const scrollTop = document.querySelector(".channels-list")?.scrollTop || 0;
this.session.channelsListPosition = scrollTop;
}
@bind
_applyScrollPosition() {
if (this.chatStateManager.isDrawerActive) {
return;
}
const position = this.chatStateManager.isFullPageActive
? this.session.channelsListPosition || 0
: 0;
const scroller = document.querySelector(".channels-list");
scroller.scrollTo(0, position);
}
<template>
{{#if this.showMobileDirectMessageButton}}
<DButton
@icon="plus"
class="no-text btn-flat open-new-message-btn keep-mobile-sidebar-open btn-floating"
@action={{this.openNewMessageModal}}
title={{i18n this.createDirectMessageChannelLabel}}
/>
<ChannelsListPublic />
{{#if this.chat.userCanAccessDirectMessages}}
<ChannelsListDirect />
{{/if}}
<div
role="region"
aria-label={{i18n "chat.aria_roles.channels_list"}}
class={{concatClass
"channels-list"
(if this.hasScrollbar "has-scrollbar")
}}
{{on
"scroll"
(if
this.chatStateManager.isFullPageActive this.storeScrollPosition (noop)
)
}}
{{didInsert this.computeHasScrollbar}}
{{onResize this.computeResizedEntries}}
>
<LinkTo @route="chat.threads" class="chat-channel-row --threads">
<span class="chat-channel-title">
{{dIcon "discourse-threads" class="chat-user-threads__icon"}}
{{i18n "chat.my_threads.title"}}
</span>
{{#if this.hasUnreadThreads}}
<div class="c-unread-indicator">
<div class="c-unread-indicator__number">&nbsp;</div>
</div>
{{/if}}
</LinkTo>
{{#if this.displayPublicChannels}}
<div class="chat-channel-divider public-channels-section">
{{#if this.inSidebar}}
<span
class="title-caret"
id="public-channels-caret"
role="button"
title="toggle nav list"
{{on "click" (fn this.toggleChannelSection "public-channels")}}
data-toggleable="public-channels"
>
{{dIcon "angle-up"}}
</span>
{{/if}}
<span class="channel-title">{{i18n "chat.chat_channels"}}</span>
<LinkTo
@route="chat.browse"
class="btn no-text btn-flat open-browse-page-btn title-action"
title={{i18n "chat.channels_list_popup.browse"}}
>
{{dIcon "pencil-alt"}}
</LinkTo>
</div>
<div
id="public-channels"
class={{concatClass
"channels-list-container"
"public-channels"
(if this.inSidebar "collapsible-sidebar-section")
}}
>
{{#if this.publicChannelsEmpty}}
<div class="public-channel-empty-message">
<span class="channel-title">{{i18n
"chat.no_public_channels"
}}</span>
<LinkTo @route="chat.browse">
{{i18n "chat.click_to_join"}}
</LinkTo>
</div>
{{else}}
{{#each
this.chatChannelsManager.publicMessageChannels
as |channel|
}}
<ChatChannelRow
@channel={{channel}}
@options={{hash settingsButton=true}}
/>
{{/each}}
{{/if}}
</div>
{{/if}}
<PluginOutlet
@name="below-public-chat-channels"
@tagName=""
@outletArgs={{hash inSidebar=this.inSidebar}}
/>
{{#if this.showDirectMessageChannels}}
<div class="chat-channel-divider direct-message-channels-section">
{{#if this.inSidebar}}
<span
class="title-caret"
id="direct-message-channels-caret"
role="button"
title="toggle nav list"
{{on
"click"
(fn this.toggleChannelSection "direct-message-channels")
}}
data-toggleable="direct-message-channels"
>
{{dIcon "angle-up"}}
</span>
{{/if}}
<span class="channel-title">{{i18n
"chat.direct_messages.title"
}}</span>
{{#if
(and
this.canCreateDirectMessageChannel
(not this.showMobileDirectMessageButton)
)
}}
<DButton
@icon="plus"
class="no-text btn-flat open-new-message-btn"
@action={{this.openNewMessageModal}}
title={{i18n this.createDirectMessageChannelLabel}}
/>
{{/if}}
</div>
{{/if}}
<div
id="direct-message-channels"
class={{this.directMessageChannelClasses}}
>
{{#each
this.chatChannelsManager.truncatedDirectMessageChannels
as |channel|
}}
<ChatChannelRow
@channel={{channel}}
@options={{hash leaveButton=true}}
/>
{{/each}}
</div>
</div>
</template>
}

View File

@ -0,0 +1,93 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import eq from "truth-helpers/helpers/eq";
export default class ChatFooter extends Component {
@service router;
@service chat;
@service chatApi;
@tracked threadsEnabled = false;
updateThreadCount = modifier(() => {
const ajax = this.chatApi.userThreadCount();
ajax
.then((result) => {
this.threadsEnabled = result.thread_count > 0;
})
.catch((error) => {
popupAjaxError(error);
});
return () => {
ajax?.abort();
};
});
get directMessagesEnabled() {
return this.chat.userCanAccessDirectMessages;
}
get shouldRenderFooter() {
return this.directMessagesEnabled || this.threadsEnabled;
}
<template>
{{#if this.shouldRenderFooter}}
<nav class="c-footer" {{this.updateThreadCount}}>
{{#if this.directMessagesEnabled}}
<DButton
@route="chat.direct-messages"
@class={{concatClass
"btn-flat"
"c-footer__item"
(if
(eq this.router.currentRouteName "chat.direct-messages")
"--active"
)
}}
@icon="users"
@id="c-footer-direct-messages"
@translatedLabel={{i18n "chat.direct_messages.title"}}
aria-label={{i18n "chat.direct_messages.aria_label"}}
/>
{{/if}}
<DButton
@route="chat.channels"
@class={{concatClass
"btn-flat"
"c-footer__item"
(if (eq this.router.currentRouteName "chat.channels") "--active")
}}
@icon="comments"
@id="c-footer-channels"
@translatedLabel={{i18n "chat.channel_list.title"}}
aria-label={{i18n "chat.channel_list.aria_label"}}
/>
{{#if this.threadsEnabled}}
<DButton
@route="chat.threads"
@class={{concatClass
"btn-flat"
"c-footer__item"
(if (eq this.router.currentRouteName "chat.threads") "--active")
}}
@icon="discourse-threads"
@id="c-footer-threads"
@translatedLabel={{i18n "chat.my_threads.title"}}
aria-label={{i18n "chat.my_threads.aria_label"}}
/>
{{/if}}
</nav>
{{/if}}
</template>
}

View File

@ -1,9 +1,11 @@
import { hash } from "@ember/helper";
import BrowseChannelsButton from "./browse-channels-button";
import CloseDrawerButton from "./close-drawer-button";
import CloseThreadButton from "./close-thread-button";
import CloseThreadsButton from "./close-threads-button";
import FullPageButton from "./full-page-button";
import NewChannelButton from "./new-channel-button";
import NewDirectMessageButton from "./new-direct-message-button";
import OpenDrawerButton from "./open-drawer-button";
import ThreadSettingsButton from "./thread-settings-button";
import ThreadTrackingDropdown from "./thread-tracking-dropdown";
@ -15,6 +17,8 @@ const ChatNavbarActions = <template>
{{yield
(hash
OpenDrawerButton=OpenDrawerButton
BrowseChannelsButton=BrowseChannelsButton
NewDirectMessageButton=NewDirectMessageButton
NewChannelButton=NewChannelButton
ThreadTrackingDropdown=ThreadTrackingDropdown
CloseThreadButton=CloseThreadButton

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import icon from "discourse-common/helpers/d-icon";
import I18n from "I18n";
import { FOOTER_NAV_ROUTES } from "discourse/plugins/chat/discourse/lib/chat-constants";
export default class ChatNavbarBackButton extends Component {
get icon() {
@ -12,6 +13,14 @@ export default class ChatNavbarBackButton extends Component {
return this.args.title ?? I18n.t("chat.browse.back");
}
get targetRoute() {
if (FOOTER_NAV_ROUTES.includes(this.args.route)) {
return this.args.route;
} else {
return "chat";
}
}
<template>
{{#if @routeModels}}
<LinkTo
@ -28,7 +37,7 @@ export default class ChatNavbarBackButton extends Component {
</LinkTo>
{{else}}
<LinkTo
@route="chat"
@route={{this.targetRoute}}
class="c-navbar__back-button no-text btn-transparent btn"
title={{this.title}}
>

View File

@ -0,0 +1,27 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import icon from "discourse-common/helpers/d-icon";
import I18n from "I18n";
export default class ChatNavbarBrowseChannelsButton extends Component {
@service router;
browseChannelsLabel = I18n.t("chat.channels_list_popup.browse");
get showBrowseChannelsButton() {
return this.router.currentRoute.name === "chat.channels";
}
<template>
{{#if this.showBrowseChannelsButton}}
<LinkTo
@route="chat.browse"
class="btn no-text btn-flat c-navbar__browse-button"
title={{this.browseChannelsLabel}}
>
{{icon "pencil-alt"}}
</LinkTo>
{{/if}}
</template>
}

View File

@ -0,0 +1,41 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import I18n from "I18n";
import ChatModalNewMessage from "discourse/plugins/chat/discourse/components/chat/modal/new-message";
export default class ChatNavbarNewDirectMessageButton extends Component {
@service router;
@service modal;
@service chat;
buttonLabel = I18n.t("chat.channels_list_popup.browse");
get showButtonComponent() {
return (
this.router.currentRoute.name === "chat.direct-messages" &&
this.canCreateDirectMessageChannel
);
}
get canCreateDirectMessageChannel() {
return this.chat.userCanDirectMessage;
}
@action
openNewMessageModal() {
this.modal.show(ChatModalNewMessage);
}
<template>
{{#if this.showButtonComponent}}
<DButton
class="btn no-text btn-flat c-navbar__new-dm-button"
title={{this.buttonLabel}}
@action={{this.openNewMessageModal}}
@icon="plus"
/>
{{/if}}
</template>
}

View File

@ -7,11 +7,17 @@ import FullPageChat from "discourse/plugins/chat/discourse/components/full-page-
export default class ChatRoutesChannel extends Component {
@service site;
get getChannelsRoute() {
return this.args.channel.isDirectMessageChannel
? "chat.direct-messages"
: "chat.channels";
}
<template>
<div class="c-routes-channel">
<Navbar as |navbar|>
{{#if this.site.mobileView}}
<navbar.BackButton />
<navbar.BackButton @route={{this.getChannelsRoute}} />
{{/if}}
<navbar.ChannelTitle @channel={{@channel}} />
<navbar.Actions as |action|>

View File

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import i18n from "discourse-common/helpers/i18n";
import ChannelsListPublic from "discourse/plugins/chat/discourse/components/channels-list-public";
import Navbar from "discourse/plugins/chat/discourse/components/chat/navbar";
export default class ChatRoutesChannels extends Component {
@service site;
<template>
<div class="c-routes-channels">
<Navbar as |navbar|>
<navbar.Title @title={{i18n "chat.chat_channels"}} />
<navbar.Actions as |action|>
<action.BrowseChannelsButton />
</navbar.Actions>
</Navbar>
<ChannelsListPublic />
</div>
</template>
}

View File

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import i18n from "discourse-common/helpers/i18n";
import ChannelsListDirect from "discourse/plugins/chat/discourse/components/channels-list-direct";
import Navbar from "discourse/plugins/chat/discourse/components/chat/navbar";
export default class ChatRoutesDirectMessages extends Component {
@service site;
<template>
<div class="c-routes-direct-messages">
<Navbar as |navbar|>
<navbar.Title @title={{i18n "chat.direct_messages.title"}} />
<navbar.Actions as |action|>
<action.NewDirectMessageButton />
</navbar.Actions>
</Navbar>
<ChannelsListDirect />
</div>
</template>
}

View File

@ -10,13 +10,7 @@ export default class ChatRoutesThreads extends Component {
<template>
<div class="c-routes-threads">
<Navbar as |navbar|>
{{#if this.site.mobileView}}
<navbar.BackButton />
{{/if}}
<navbar.Title
@title={{i18n "chat.my_threads.title"}}
@icon="discourse-threads"
/>
<navbar.Title @title={{i18n "chat.my_threads.title"}} />
<navbar.Actions as |action|>
<action.OpenDrawerButton />

View File

@ -1,5 +1,6 @@
import Controller from "@ember/controller";
import { inject as service } from "@ember/service";
import { FOOTER_NAV_ROUTES } from "discourse/plugins/chat/discourse/lib/chat-constants";
export default class ChatController extends Controller {
@service chat;
@ -22,6 +23,13 @@ export default class ChatController extends Controller {
return this.siteSettings.navigation_menu === "sidebar";
}
get shouldUseChatFooter() {
return (
this.site.mobileView &&
FOOTER_NAV_ROUTES.includes(this.router.currentRouteName)
);
}
get mainOutletModifierClasses() {
let modifierClasses = [];

View File

@ -2,3 +2,8 @@ export const PAST = "past";
export const FUTURE = "future";
export const READ_INTERVAL_MS = 1000;
export const DEFAULT_MESSAGE_PAGE_SIZE = 50;
export const FOOTER_NAV_ROUTES = [
"chat.direct-messages",
"chat.channels",
"chat.threads",
];

View File

@ -0,0 +1,15 @@
import { inject as service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class ChatChannelsRoute extends DiscourseRoute {
@service chat;
@service chatChannelsManager;
activate() {
this.chat.activeChannel = null;
}
model() {
return this.chatChannelsManager.publicMessageChannels;
}
}

View File

@ -0,0 +1,15 @@
import { inject as service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class ChatDirectMessagesRoute extends DiscourseRoute {
@service chat;
@service chatChannelsManager;
activate() {
this.chat.activeChannel = null;
}
model() {
return this.chatChannelsManager.directMessageChannels;
}
}

View File

@ -11,9 +11,13 @@ export default class ChatIndexRoute extends DiscourseRoute {
}
redirect() {
// Always want the channel index on mobile.
// on mobile redirect user to the first footer tab route
if (this.site.mobileView) {
return;
if (this.chat.userCanAccessDirectMessages) {
return this.router.replaceWith("chat.direct-messages");
} else {
return this.router.replaceWith("chat.channels");
}
}
// We are on desktop. Check for a channel to enter and transition if so
@ -26,10 +30,4 @@ export default class ChatIndexRoute extends DiscourseRoute {
return this.router.replaceWith("chat.browse");
}
}
model() {
if (this.site.mobileView) {
return this.chatChannelsManager.channels;
}
}
}

View File

@ -28,6 +28,8 @@ export default class ChatRoute extends DiscourseRoute {
const INTERCEPTABLE_ROUTES = [
"chat.channel",
"chat.direct-messages",
"chat.channels",
"chat.threads",
"chat.channel.thread",
"chat.channel.thread.index",

View File

@ -321,6 +321,13 @@ export default class ChatApi extends Service {
return new Collection(`${this.#basePath}/me/threads`, handler);
}
/**
* Get the total number of threads for the current user.
*/
userThreadCount() {
return this.#getRequest("/me/threads/count");
}
/**
* Update notifications settings of current user for a channel.
* @param {number} channelId - The ID of the channel.

View File

@ -74,6 +74,15 @@ export default class Chat extends Service {
);
}
@computed("chatChannelsManager.directMessageChannels")
get userHasDirectMessages() {
return this.chatChannelsManager.directMessageChannels?.length > 0;
}
get userCanAccessDirectMessages() {
return this.userCanDirectMessage || this.userHasDirectMessages;
}
@computed("activeChannel.userSilenced")
get userCanInteractWithChat() {
return !this.activeChannel?.userSilenced;

View File

@ -0,0 +1 @@
<Chat::Routes::Channels />

View File

@ -0,0 +1 @@
<Chat::Routes::DirectMessages />

View File

@ -22,6 +22,9 @@
class={{concat-class "main-chat-outlet" this.mainOutletModifierClasses}}
>
{{outlet}}
{{#if this.shouldUseChatFooter}}
<ChatFooter />
{{/if}}
</div>
</div>
{{/if}}

View File

@ -1,11 +1,11 @@
.btn-floating.open-new-message-btn {
position: fixed;
background: var(--tertiary);
bottom: 2rem;
bottom: 5rem;
right: 2rem;
border-radius: 50%;
font-size: var(--font-up-4);
padding: 1rem;
font-size: var(--font-up-3);
padding: 0.5rem;
transition: transform 0.25s ease, box-shadow 0.25s ease;
z-index: z("usercard");
box-shadow: 0px 5px 5px -1px rgba(0, 0, 0, 0.25);
@ -27,9 +27,6 @@
}
.channels-list {
overflow-y: auto;
overscroll-behavior: contain;
height: 100%;
padding-bottom: env(safe-area-inset-bottom);
position: relative;
@include chat-scrollbar();
@ -65,8 +62,8 @@
}
}
.public-channel-empty-message {
margin: 0 0.5em 0.5em 0.5em;
.channel-list-empty-message {
margin: 1em 0.5em 0.5em 0.5em;
padding: 0 1em;
}

View File

@ -32,10 +32,6 @@
@include ellipsis();
font-weight: 700;
.c-routes-channel-threads & {
padding-left: 0;
}
.chat-drawer & {
padding-left: 1rem;
}

View File

@ -7,7 +7,6 @@
}
.chat-side-panel {
grid-area: threads;
box-sizing: border-box;
border-left: 1px solid var(--primary-low);
position: relative;

View File

@ -63,7 +63,7 @@
margin-bottom: 1rem;
}
.public-channel-empty-message {
.channel-list-empty-message {
margin: 0;
padding: 0em 2em 0.5em;
}

View File

@ -24,7 +24,6 @@ html.has-full-page-chat {
&.has-side-panel-expanded {
grid-template-columns: 1fr;
grid-template-areas: "threads";
.c-routes-channel {
display: none;
@ -40,8 +39,6 @@ html.has-full-page-chat {
.btn:active,
.btn:hover {
background: var(--secondary-very-high);
.d-icon {
color: var(--primary-medium);
}

View File

@ -0,0 +1,56 @@
.full-page-chat {
#main-chat-outlet.chat-view {
grid-template-areas:
"list"
"footer";
}
.channels-list {
grid-area: list;
padding-bottom: 0;
}
.c-footer {
grid-area: footer;
background: var(--secondary);
border-top: 1px solid var(--primary-low);
display: flex;
align-items: flex-end;
justify-content: space-around;
position: sticky;
bottom: 0;
left: 0;
&__item {
display: flex;
flex-direction: column;
align-items: center;
flex-basis: 33%;
flex-shrink: 0;
padding-block: 0.75rem;
height: 100%;
&.--active {
.d-icon,
.d-button-label {
color: var(--quaternary);
}
}
.d-icon {
margin-right: 0;
font-size: var(--font-up-4);
color: var(--primary-medium);
&.d-icon-discourse-threads {
font-size: var(--font-up-3); //visual correction
}
}
.d-button-label {
font-size: var(--font-down-1-rem);
color: var(--primary-medium);
}
}
}
}

View File

@ -2,7 +2,6 @@
.full-page-chat {
.channels-list {
overflow-y: overlay;
padding-bottom: 6rem;
box-sizing: border-box;
@ -19,7 +18,7 @@
}
.channel-title {
color: var(--quaternary);
color: var(--primary);
font-size: var(--font-down-1);
}
}
@ -73,3 +72,10 @@
}
}
}
.c-routes-direct-messages,
.c-routes-channels,
.c-routes-threads {
background: var(--primary-very-low);
max-width: 100vw;
}

View File

@ -1,4 +1,7 @@
.chat-message-thread-indicator {
.c-routes-threads & {
background: var(--secondary);
}
grid-template-areas:
"avatar info info participants"
"excerpt excerpt excerpt replies";

View File

@ -4,8 +4,18 @@
max-width: 100vw;
padding-inline: 0.25rem;
}
&__actions {
margin-right: 0.5rem;
}
&__back-button {
align-self: stretch;
}
.c-navbar__title {
.c-routes-direct-messages &,
.c-routes-channels &,
.c-routes-threads & {
padding-left: 1.25rem;
}
}
}

View File

@ -3,3 +3,7 @@
justify-content: space-between;
}
}
.c-user-thread {
margin-inline: 1.5rem;
}

View File

@ -22,3 +22,4 @@
@import "chat-thread-list-header";
@import "chat-user-threads";
@import "chat-header";
@import "chat-footer";

View File

@ -226,6 +226,7 @@ en:
composer: "Chat composer"
channels_list: "Chat channels list"
no_direct_message_channels: "You have not joined any direct message channels."
no_public_channels: "You have not joined any channels."
kicked_from_channel: "You can no longer access this channel."
only_chat_push_notifications:
@ -497,16 +498,22 @@ en:
export_has_started: "The export has started. You'll receive a PM when it's ready."
my_threads:
title: My threads
title: My Threads
aria_label: "Switch to my threads list"
direct_messages:
title: "Personal chat"
title: "DMs"
aria_label: "Switch to direct messages"
new: "Create a personal chat"
create: "Go"
leave: "Leave this personal chat"
close: "Close this personal chat"
cannot_create: "Sorry, you cannot send direct messages."
channel_list:
title: "Channels"
aria_label: "Switch to channels list"
incoming_webhooks:
back: "Back"
channel_placeholder: "Select a channel"

View File

@ -6,6 +6,7 @@ Chat::Engine.routes.draw do
get "/channels" => "channels#index"
get "/me/channels" => "current_user_channels#index"
get "/me/threads" => "current_user_threads#index"
get "/me/threads/count" => "current_user_threads#thread_count"
post "/channels" => "channels#create"
put "/channels/read/" => "reads#update_all"
put "/channels/:channel_id/read/:message_id" => "reads#update"
@ -73,6 +74,8 @@ Chat::Engine.routes.draw do
# chat_controller routes
get "/" => "chat#respond"
get "/direct-messages" => "chat#respond"
get "/channels" => "chat#respond"
get "/threads" => "chat#respond"
get "/browse" => "chat#respond"
get "/browse/all" => "chat#respond"

View File

@ -29,4 +29,24 @@ describe Chat::Api::CurrentUserThreadsController do
end
end
end
describe "#thread_count" do
describe "success" do
it "works" do
get "/chat/api/me/threads/count"
expect(response.status).to eq(200)
expect(response.parsed_body["thread_count"]).to eq(0)
end
end
context "when threads are not found" do
it "returns a 200 when there are no threads" do
get "/chat/api/me/threads/count"
expect(response.status).to eq(200)
expect(response.parsed_body["thread_count"]).to eq(0)
end
end
end
end

View File

@ -69,7 +69,7 @@ RSpec.describe "Browse page", type: :system do
chat_page.visit_browse
find(".c-navbar__back-button").click
expect(browse_page).to have_current_path("/chat")
expect(browse_page).to have_current_path("/chat/direct-messages")
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
RSpec.describe "Chat footer on mobile", type: :system, mobile: true do
fab!(:user)
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: user) }
let(:chat_page) { PageObjects::Pages::Chat.new }
before do
chat_system_bootstrap
sign_in(user)
channel.add(user)
end
context "with multiple tabs" do
it "shows footer" do
visit("/")
chat_page.open_from_header
expect(page).to have_css(".c-footer")
expect(page).to have_css(".c-footer__item", count: 2)
expect(page).to have_css("#c-footer-direct-messages")
expect(page).to have_css("#c-footer-channels")
end
it "hides footer when channel is open" do
chat_page.visit_channel(channel)
expect(page).to have_no_css(".c-footer")
end
it "redirects the user to the direct messages tab" do
visit("/")
chat_page.open_from_header
expect(page).to have_current_path("/chat/direct-messages")
end
it "shows threads tab when user has threads" do
thread = Fabricate(:chat_thread, channel: channel, original_message: message)
Fabricate(:chat_message, chat_channel: channel, thread: thread)
thread.update!(replies_count: 1)
visit("/")
chat_page.open_from_header
expect(page).to have_css(".c-footer")
expect(page).to have_css("#c-footer-threads")
end
end
context "with only 1 tab" do
before do
SiteSetting.direct_message_enabled_groups = "3" # staff only
end
it "does not render footer" do
visit("/")
chat_page.open_from_header
expect(page).to have_no_css(".c-footer")
end
it "redirects user to channels page" do
visit("/")
chat_page.open_from_header
expect(page).to have_current_path("/chat/channels")
end
end
end

View File

@ -19,14 +19,14 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
before { category_channel_1.add(current_user) }
it "shows the channel in the correct section" do
visit("/chat")
visit("/chat/channels")
expect(page.find(".public-channels")).to have_content(category_channel_1.name)
end
end
context "when not member of the channel" do
it "doesnt show the channel" do
visit("/chat")
visit("/chat/channels")
expect(page.find(".public-channels", visible: :all)).to have_no_content(
category_channel_1.name,
@ -46,6 +46,7 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
it "sorts them alphabetically" do
visit("/chat")
page.find("#c-footer-channels").click
expect(page.find("#public-channels a:nth-child(1)")["data-chat-channel-id"]).to eq(
channel_2.id.to_s,
@ -77,25 +78,25 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
end
context "when no category channels" do
it "doesnt show the section" do
visit("/chat")
expect(page).to have_no_css(".public-channels-section")
it "hides the section" do
visit("/chat/channels")
expect(page).to have_no_css(".channels-list-container")
end
context "when user can create channels" do
before { current_user.update!(admin: true) }
it "shows the section" do
visit("/chat")
expect(page).to have_css(".public-channels-section")
visit("/chat/channels")
expect(page).to have_css(".channels-list-container")
end
end
end
context "when no direct message channels" do
it "shows the section" do
visit("/chat")
expect(page).to have_css(".direct-message-channels-section")
visit("/chat/direct-messages")
expect(page).to have_selector(".channels-list-container")
end
end
@ -123,8 +124,8 @@ RSpec.describe "List channels | mobile", type: :system, mobile: true do
end
it "has a new dm channel button" do
visit("/chat")
find(".open-new-message-btn").click
visit("/chat/direct-messages")
find(".c-navbar__new-dm-button").click
expect(chat.message_creator).to be_opened
end

View File

@ -80,7 +80,7 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
context "when a message is created" do
it "correctly renders notifications" do
visit("/chat")
visit("/chat/channels")
create_message(channel_1, user: user_1)
@ -93,7 +93,7 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
it "correctly renders notifications" do
Jobs.run_immediately!
visit("/chat")
visit("/chat/channels")
create_message(
channel_1,
@ -173,13 +173,15 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
context "when messages are created" do
it "correctly renders notifications" do
visit("/chat")
visit("/chat/channels")
create_message(channel_1, user: user_1)
expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator", text: "")
expect(channel_index_page).to have_unread_channel(channel_1)
visit("/chat/direct-messages")
create_message(dm_channel_1, user: user_1)
expect(channel_index_page).to have_unread_channel(dm_channel_1)

View File

@ -38,11 +38,11 @@ RSpec.describe "Navigation", type: :system do
end
context "when clicking chat icon on mobile and is viewing channel" do
it "navigates to index", mobile: true do
it "navigates to direct messages tab", mobile: true do
chat_page.visit_channel(category_channel_2)
chat_page.open_from_header
expect(page).to have_current_path(chat_path)
expect(page).to have_current_path("/chat/direct-messages")
end
end

View File

@ -6,7 +6,7 @@ module PageObjects
class ChannelIndex < PageObjects::Components::Base
attr_reader :context
SELECTOR = ".channels-list"
SELECTOR = ".channels-list:first-child"
def initialize(context = nil)
@context = context