FEATURE: my threads page (#24771)

This commit adds a new "My threads" link in sidebar and drawer. This link will open the "/chat/threads" page which contains all threads where the current user is a member. It's ordered by activity (unread and then last message created).

Moreover, the threads list of a channel page is now showing every threads of a channel, and not just the ones where you are a member.
This commit is contained in:
Joffrey JAFFEUX
2023-12-11 07:38:07 +01:00
committed by GitHub
parent 4949d85c15
commit 09277bc543
75 changed files with 1419 additions and 227 deletions

View File

@ -8,6 +8,8 @@ export default function () {
});
});
this.route("threads", { path: "/threads" });
this.route(
"channel.info",
{ path: "/c/:channelTitle/:channelId/info" },

View File

@ -0,0 +1,111 @@
import Component from "@glimmer/component";
import { get, hash } from "@ember/helper";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import UserStatusMessage from "discourse/components/user-status-message";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
export default class ChatChannelTitle extends Component {
@service currentUser;
get firstUser() {
return this.args.channel.chatable.users[0];
}
get users() {
return this.args.channel.chatable.users;
}
get groupDirectMessage() {
return (
this.args.channel.isDirectMessageChannel &&
this.args.channel.chatable.group
);
}
get groupsDirectMessageTitle() {
return this.args.channel.title || this.usernames;
}
get usernames() {
return this.users.mapBy("username").join(", ");
}
get channelColorStyle() {
return htmlSafe(`color: #${this.args.channel.chatable.color}`);
}
get showUserStatus() {
return !!(this.users.length === 1 && this.users[0].status);
}
<template>
{{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-dm">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__users-count">
{{@channel.membershipsCount}}
</span>
{{else}}
<div class="chat-channel-title__avatar">
<ChatUserAvatar @user={{this.firstUser}} @interactive={{false}} />
</div>
{{/if}}
<div class="chat-channel-title__user-info">
<div class="chat-channel-title__usernames">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__name">
{{this.groupsDirectMessageTitle}}
</span>
{{else}}
<span class="chat-channel-title__name">
{{this.firstUser.username}}
</span>
{{#if this.showUserStatus}}
<UserStatusMessage
@class="chat-channel-title__user-status-message"
@status={{get this.users "0.status"}}
@showDescription={{if this.site.mobileView "true"}}
/>
{{/if}}
<PluginOutlet
@name="after-chat-channel-username"
@outletArgs={{hash user=@user}}
@tagName=""
@connectorTagName=""
/>
{{/if}}
</div>
</div>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{else if @channel.isCategoryChannel}}
<div class="chat-channel-title is-category">
<span
class="chat-channel-title__category-badge"
style={{this.channelColorStyle}}
>
{{icon "d-chat"}}
{{#if @channel.chatable.read_restricted}}
{{icon "lock" class="chat-channel-title__restricted-category-icon"}}
{{/if}}
</span>
<span class="chat-channel-title__name">
{{replaceEmoji @channel.title}}
</span>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -100,6 +100,12 @@ export default class ChannelsList extends Component {
}`;
}
get hasUnreadThreads() {
return this.chatChannelsManager.publicMessageChannels.some(
(channel) => channel.unreadThreadsCount > 0
);
}
@action
toggleChannelSection(section) {
this.args.toggleSection(section);
@ -160,6 +166,24 @@ export default class ChannelsList extends Component {
{{didInsert this.computeHasScrollbar}}
{{onResize this.computeResizedEntries}}
>
<div class="channels-list-container user-threads-section">
<LinkTo @route="chat.threads" class="chat__user-threads-row-container">
<div class="chat__user-threads-row">
<span class="chat__user-threads-row__title">
{{dIcon "discourse-threads" class="chat__user-threads-row__icon"}}
{{i18n "chat.my_threads.title"}}
</span>
{{#if this.hasUnreadThreads}}
<div class="chat__unread-indicator">
<div class="chat__unread-indicator__number">&nbsp;</div>
</div>
{{/if}}
</div>
</LinkTo>
</div>
{{#if this.displayPublicChannels}}
<div class="chat-channel-divider public-channels-section">
{{#if this.inSidebar}}

View File

@ -1,6 +1,6 @@
<div class="select-kit-header-wrapper">
{{#if this.selectedContent}}
<ChatChannelTitle @channel={{this.selectedContent}} />
<ChannelTitle @channel={{this.selectedContent}} />
{{else}}
{{i18n "chat.incoming_webhooks.channel_placeholder"}}
{{/if}}

View File

@ -1 +1 @@
<ChatChannelTitle @channel={{this.item}} />
<ChannelTitle @channel={{this.item}} />

View File

@ -5,9 +5,9 @@ import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class ChatChannelMessageEmojiPicker extends Component {
@service chatChannelInfoRouteOriginManager;
@ -64,7 +64,7 @@ export default class ChatChannelMessageEmojiPicker extends Component {
{{/if}}
</div>
<ChatChannelTitle @channel={{@channel}} />
<ChannelTitle @channel={{@channel}} />
{{#if this.canEditChannel}}
<DButton

View File

@ -5,7 +5,7 @@ import { inject as service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
import ChatChannelTitle from "./chat-channel-title";
import ChannelTitle from "./channel-title";
import ToggleChannelMembershipButton from "./toggle-channel-membership-button";
export default class ChatChannelPreviewCard extends Component {
@ -27,7 +27,7 @@ export default class ChatChannelPreviewCard extends Component {
(unless this.showJoinButton "-no-button")
}}
>
<ChatChannelTitle @channel={{@channel}} />
<ChannelTitle @channel={{@channel}} />
{{#if this.hasDescription}}
<p class="chat-channel-preview-card__description">
{{@channel.description}}

View File

@ -15,8 +15,8 @@ import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import and from "truth-helpers/helpers/and";
import eq from "truth-helpers/helpers/eq";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
import ChatChannelMetadata from "discourse/plugins/chat/discourse/components/chat-channel-metadata";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
import ToggleChannelMembershipButton from "discourse/plugins/chat/discourse/components/toggle-channel-membership-button";
const FADEOUT_CLASS = "-fade-out";
@ -184,7 +184,7 @@ export default class ChatChannelRow extends Component {
{{(if this.shouldReset (modifier this.onReset))}}
style={{this.rowStyle}}
>
<ChatChannelTitle @channel={{@channel}} />
<ChannelTitle @channel={{@channel}} />
<ChatChannelMetadata @channel={{@channel}} @unreadIndicator={{true}} />
{{#if

View File

@ -1,111 +1,8 @@
import Component from "@glimmer/component";
import { get, hash } from "@ember/helper";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import UserStatusMessage from "discourse/components/user-status-message";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-user-avatar";
export default class ChatChannelTitle extends Component {
@service currentUser;
get firstUser() {
return this.args.channel.chatable.users[0];
}
get users() {
return this.args.channel.chatable.users;
}
get groupDirectMessage() {
return (
this.args.channel.isDirectMessageChannel &&
this.args.channel.chatable.group
);
}
get groupsDirectMessageTitle() {
return this.args.channel.title || this.usernames;
}
get usernames() {
return this.users.mapBy("username").join(", ");
}
get channelColorStyle() {
return htmlSafe(`color: #${this.args.channel.chatable.color}`);
}
get showUserStatus() {
return !!(this.users.length === 1 && this.users[0].status);
}
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
export default class OldChatChannelTitle extends Component {
<template>
{{#if @channel.isDirectMessageChannel}}
<div class="chat-channel-title is-dm">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__users-count">
{{@channel.membershipsCount}}
</span>
{{else}}
<div class="chat-channel-title__avatar">
<ChatUserAvatar @user={{this.firstUser}} @interactive={{false}} />
</div>
{{/if}}
<div class="chat-channel-title__user-info">
<div class="chat-channel-title__usernames">
{{#if this.groupDirectMessage}}
<span class="chat-channel-title__name">
{{this.groupsDirectMessageTitle}}
</span>
{{else}}
<span class="chat-channel-title__name">
{{this.firstUser.username}}
</span>
{{#if this.showUserStatus}}
<UserStatusMessage
@class="chat-channel-title__user-status-message"
@status={{get this.users "0.status"}}
@showDescription={{if this.site.mobileView "true"}}
/>
{{/if}}
<PluginOutlet
@name="after-chat-channel-username"
@outletArgs={{hash user=@user}}
@tagName=""
@connectorTagName=""
/>
{{/if}}
</div>
</div>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{else if @channel.isCategoryChannel}}
<div class="chat-channel-title is-category">
<span
class="chat-channel-title__category-badge"
style={{this.channelColorStyle}}
>
{{icon "d-chat"}}
{{#if @channel.chatable.read_restricted}}
{{icon "lock" class="chat-channel-title__restricted-category-icon"}}
{{/if}}
</span>
<span class="chat-channel-title__name">
{{replaceEmoji @channel.title}}
</span>
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
{{/if}}
<ChannelTitle @channel={{@channel}} />
</template>
}

View File

@ -10,7 +10,7 @@ import ChatDrawerHeaderRightActions from "discourse/plugins/chat/discourse/compo
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 {
export default class ChatDrawerChannelThreads extends Component {
@service appEvents;
@service chat;
@service chatStateManager;

View File

@ -2,7 +2,7 @@ import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import ChatChannelTitle from "../../chat-channel-title";
import ChannelTitle from "../../channel-title";
export default class ChatDrawerChannelHeaderTitle extends Component {
@service chatStateManager;
@ -20,7 +20,7 @@ export default class ChatDrawerChannelHeaderTitle extends Component {
class="chat-drawer-header__title"
>
<div class="chat-drawer-header__top-line">
<ChatChannelTitle @channel={{@channel}} />
<ChannelTitle @channel={{@channel}} />
</div>
</LinkTo>
{{else}}
@ -30,13 +30,13 @@ export default class ChatDrawerChannelHeaderTitle extends Component {
class="chat-drawer-header__title"
>
<div class="chat-drawer-header__top-line">
<ChatChannelTitle @channel={{@channel}}>
<ChannelTitle @channel={{@channel}}>
{{#if @channel.tracking.unreadCount}}
<span class="chat-unread-count">
{{@channel.tracking.unreadCount}}
</span>
{{/if}}
</ChatChannelTitle>
</ChannelTitle>
</div>
</div>
{{/if}}

View File

@ -27,6 +27,10 @@ export default class ChatDrawerThread extends Component {
if (this.chatHistory.previousRoute?.name === "chat.channel.threads") {
link.title = I18n.t("chat.return_to_threads_list");
link.route = "chat.channel.threads";
} else if (this.chatHistory.previousRoute?.name === "chat.threads") {
link.title = I18n.t("chat.my_threads.title");
link.route = "chat.threads";
link.models = [];
} else {
link.title = I18n.t("chat.return_to_channel");
link.route = "chat.channel";

View File

@ -0,0 +1,47 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import I18n from "discourse-i18n";
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 UserThreads from "discourse/plugins/chat/discourse/components/user-threads";
export default class ChatDrawerThreads extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
backLinkTitle = I18n.t("chat.return_to_list");
<template>
<ChatDrawerHeader @toggleExpand={{@drawerActions.toggleExpand}}>
{{#if this.chatStateManager.isDrawerExpanded}}
<div class="chat-drawer-header__left-actions">
<div class="chat-drawer-header__top-line">
<ChatDrawerHeaderBackLink
@route="chat"
@title={{this.backLink.title}}
/>
</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">
<UserThreads />
</div>
{{/if}}
</template>
}

View File

@ -1,4 +1,5 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
@ -7,10 +8,10 @@ import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import and from "truth-helpers/helpers/and";
import or from "truth-helpers/helpers/or";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import ThreadsListButton from "discourse/plugins/chat/discourse/components/chat/thread/threads-list-button";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
export default class ChatFullPageHeader extends Component {
@service chatStateManager;
@ -38,13 +39,20 @@ export default class ChatFullPageHeader extends Component {
});
}
@action
trapMouse(event) {
event.stopPropagation();
}
<template>
{{! template-lint-disable no-invalid-interactive }}
{{#if (and this.chatStateManager.isFullPageActive this.displayed)}}
<div
class={{concatClass
"chat-full-page-header"
(unless @channel.isFollowing "-not-following")
}}
{{on "mousemove" this.trapMouse}}
>
<div class="chat-channel-header-details">
{{#if this.site.mobileView}}
@ -63,7 +71,7 @@ export default class ChatFullPageHeader extends Component {
@models={{@channel.routeModels}}
class="chat-channel-title-wrapper"
>
<ChatChannelTitle @channel={{@channel}} />
<ChannelTitle @channel={{@channel}} />
</LinkTo>
{{#if (or @channel.threadingEnabled this.site.desktopView)}}

View File

@ -9,6 +9,7 @@ import formatDate from "discourse/helpers/format-date";
import replaceEmoji from "discourse/helpers/replace-emoji";
import htmlSafe from "discourse-common/helpers/html-safe";
import i18n from "discourse-common/helpers/i18n";
import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import ChatThreadParticipants from "./chat-thread-participants";
import ChatUserAvatar from "./chat-user-avatar";
@ -22,6 +23,10 @@ export default class ChatMessageThreadIndicator extends Component {
@tracked isActive = false;
get interactiveUser() {
return this.args.interactiveUser ?? true;
}
@action
setup(element) {
this.element = element;
@ -37,7 +42,11 @@ export default class ChatMessageThreadIndicator extends Component {
this.element.addEventListener("touchCancel", this.cancelTouch);
}
this.element.addEventListener("click", this.openThread, {
this.element.addEventListener("mousedown", this.openThread, {
passive: true,
});
this.element.addEventListener("keydown", this.openThread, {
passive: true,
});
}
@ -55,7 +64,11 @@ export default class ChatMessageThreadIndicator extends Component {
this.element.removeEventListener("touchCancel", this.cancelTouch);
}
this.element.removeEventListener("click", this.openThread, {
this.element.removeEventListener("mousedown", this.openThread, {
passive: true,
});
this.element.removeEventListener("keydown", this.openThread, {
passive: true,
});
}
@ -84,7 +97,25 @@ export default class ChatMessageThreadIndicator extends Component {
}
@bind
openThread() {
openThread(event) {
if (event.type === "keydown" && event.key !== "Enter") {
return;
}
// handle middle mouse
if (event.type === "mousedown" && (event.which === 2 || event.shiftKey)) {
window.open(
getURL(
this.router.urlFor(
"chat.channel.thread",
...this.args.message.thread.routeModels
)
),
"_blank"
);
return;
}
this.chat.activeMessage = null;
this.router.transitionTo(
@ -103,12 +134,14 @@ export default class ChatMessageThreadIndicator extends Component {
{{willDestroy this.teardown}}
role="button"
title={{i18n "chat.threads.open"}}
tabindex="0"
>
<div class="chat-message-thread-indicator__last-reply-avatar">
<ChatUserAvatar
@user={{@message.thread.preview.lastReplyUser}}
@avatarSize="small"
@interactive={{this.interactiveUser}}
/>
</div>

View File

@ -76,12 +76,10 @@ export default class ChatThreadList extends Component {
// NOTE: This replicates sort logic from the server. We need this because
// the thread unread count + last reply date + time update when new messages
// are sent to the thread, and we want the list to react in realtime to this.
@cached
get sortedThreads() {
return this.threadsManager.threads
.filter(
(thread) =>
thread.currentUserMembership && !thread.originalMessage.deletedAt
)
.filter((thread) => !thread.originalMessage.deletedAt)
.sort((threadA, threadB) => {
// If both are unread we just want to sort by last reply date + time descending.
if (threadA.tracking.unreadCount && threadB.tracking.unreadCount) {
@ -186,6 +184,7 @@ export default class ChatThreadList extends Component {
{{/if}}
<div class="chat-thread-list__items" {{this.fill}}>
{{#each this.sortedThreads key="id" as |thread|}}
<ChatThreadListItem
@thread={{thread}}

View File

@ -4,6 +4,10 @@ import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat-use
export default class ChatThreadParticipants extends Component {
get showParticipants() {
if (!this.args.thread) {
return;
}
if (this.includeOriginalMessageUser) {
return this.participantsUsers.length > 1;
}

View File

@ -2,7 +2,7 @@ import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import concatClass from "discourse/helpers/concat-class";
import gt from "truth-helpers/helpers/gt";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
export default class Channel extends Component {
@service currentUser;
@ -17,7 +17,7 @@ export default class Channel extends Component {
<template>
<div class="chat-message-creator__chatable -category-channel">
<ChatChannelTitle @channel={{@item.model}} />
<ChannelTitle @channel={{@item.model}} />
{{#if (gt @item.tracking.unreadCount 0)}}

View File

@ -8,9 +8,9 @@ import formatDate from "discourse/helpers/format-date";
import replaceEmoji from "discourse/helpers/replace-emoji";
import i18n from "discourse-common/helpers/i18n";
import gt from "truth-helpers/helpers/gt";
import ThreadUnreadIndicator from "discourse/plugins/chat/discourse/components/thread-unread-indicator";
import ChatThreadParticipants from "../../chat-thread-participants";
import ChatUserAvatar from "../../chat-user-avatar";
import UnreadIndicator from "./item/unread-indicator";
export default class ChatThreadListItem extends Component {
@service router;
@ -45,7 +45,7 @@ export default class ChatThreadListItem extends Component {
{{/if}}
</div>
<div class="chat-thread-list-item__unread-indicator">
<UnreadIndicator @thread={{@thread}} />
<ThreadUnreadIndicator @thread={{@thread}} />
</div>
</div>

View File

@ -30,24 +30,27 @@ export default class ChatThreadHeader extends Component {
get backLink() {
const prevPage = this.chatHistory.previousRoute?.name;
let route, title;
let route, title, models;
if (prevPage === "chat.channel.threads") {
route = "chat.channel.threads";
title = I18n.t("chat.return_to_threads_list");
models = this.args.channel.routeModels;
} else if (prevPage === "chat.channel.index" && !this.site.mobileView) {
route = "chat.channel.threads";
title = I18n.t("chat.return_to_threads_list");
models = this.args.channel.routeModels;
} else if (prevPage === "chat.threads") {
route = "chat.threads";
title = I18n.t("chat.my_threads.title");
models = [];
} else {
route = "chat.channel.index";
title = I18n.t("chat.return_to_channel");
models = this.args.channel.routeModels;
}
return {
route,
models: this.args.channel.routeModels,
title,
};
return { route, models, title };
}
get canChangeThreadSettings() {

View File

@ -0,0 +1,20 @@
import Component from "@glimmer/component";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import Navbar from "discourse/plugins/chat/discourse/components/navbar";
import UserThreads from "discourse/plugins/chat/discourse/components/user-threads";
export default class ChatThreads extends Component {
<template>
<div class="chat-threads">
<Navbar>
<:current>
{{icon "discourse-threads"}}
{{i18n "chat.my_threads.title"}}
</:current>
</Navbar>
<UserThreads />
</div>
</template>
}

View File

@ -0,0 +1,44 @@
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 DiscourseURL from "discourse/lib/url";
export default class ChatNavbar extends Component {
@service chatStateManager;
@action
async closeFullScreen() {
this.chatStateManager.prefersDrawer();
try {
await DiscourseURL.routeTo(this.chatStateManager.lastKnownAppURL);
await DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL);
} catch (error) {
await DiscourseURL.routeTo("/");
}
}
<template>
<div class="chat-navbar-container">
<nav class="chat-navbar">
{{#if (has-block "current")}}
<span class="chat-navbar__current">
{{yield to="current"}}
</span>
{{/if}}
<ul class="chat-navbar__right-actions">
<li class="chat-navbar__right-action">
<DButton
@icon="discourse-compress"
@title="chat.close_full_page"
class="open-drawer-btn btn-flat"
@action={{this.closeFullScreen}}
/>
</li>
</ul>
</nav>
</div>
</template>
}

View File

@ -8,15 +8,15 @@ import ReviewablePostHeader from "discourse/components/reviewable-post-header";
import htmlSafe from "discourse-common/helpers/html-safe";
import i18n from "discourse-common/helpers/i18n";
import or from "truth-helpers/helpers/or";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import ChatChannelTitle from "./chat-channel-title";
export default class ReviewableChatMessage extends Component {
@service store;
@service chatChannelsManager;
@cached
get chatChannel() {
get channel() {
return ChatChannel.create(this.args.reviewable.chat_channel);
}
@ -25,12 +25,12 @@ export default class ReviewableChatMessage extends Component {
<LinkTo
@route="chat.channel.near-message"
@models={{array
this.chatChannel.slugifiedTitle
this.chatChannel.id
this.channel.slugifiedTitle
this.channel.id
@reviewable.target_id
}}
>
<ChatChannelTitle @channel={{this.chatChannel}} />
<ChannelTitle @channel={{this.channel}} />
</LinkTo>
</div>

View File

@ -0,0 +1,27 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import replaceEmoji from "discourse/helpers/replace-emoji";
import { escapeExpression } from "discourse/lib/utilities";
import ThreadUnreadIndicator from "discourse/plugins/chat/discourse/components/thread-unread-indicator";
export default class ChatThreadTitle extends Component {
get title() {
if (this.args.thread.title) {
return replaceEmoji(htmlSafe(escapeExpression(this.args.thread.title)));
} else {
return replaceEmoji(htmlSafe(this.args.thread.originalMessage.excerpt));
}
}
<template>
<div class="chat__thread-title-container">
<div class="chat__thread-title">
<span class="chat__thread-title__name">
{{this.title}}
</span>
<ThreadUnreadIndicator @thread={{@thread}} />
</div>
</div>
</template>
}

View File

@ -1,6 +1,6 @@
import Component from "@glimmer/component";
export default class ChatThreadListItemUnreadIndicator extends Component {
export default class ChatThreadUnreadIndicator extends Component {
get unreadCount() {
return this.args.thread.tracking.unreadCount;
}

View File

@ -0,0 +1,107 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
import ThreadIndicator from "discourse/plugins/chat/discourse/components/chat-message-thread-indicator";
import ThreadTitle from "discourse/plugins/chat/discourse/components/thread-title";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
export default class UserThreads extends Component {
@service chat;
@service chatApi;
@service router;
loadMore = modifier((element) => {
this.intersectionObserver = new IntersectionObserver(this.loadThreads);
this.intersectionObserver.observe(element);
return () => {
this.intersectionObserver.disconnect();
};
});
fill = modifier((element) => {
this.resizeObserver = new ResizeObserver(() => {
if (isElementInViewport(element)) {
this.loadThreads();
}
});
this.resizeObserver.observe(element);
return () => {
this.resizeObserver.disconnect();
};
});
@cached
get threadsCollection() {
return this.chatApi.userThreads(this.handleLoadedThreads);
}
@action
loadThreads() {
discourseDebounce(this, this.debouncedLoadThreads, INPUT_DELAY);
}
async debouncedLoadThreads() {
await this.threadsCollection.load({ limit: 10 });
}
@bind
handleLoadedThreads(result) {
return result.threads.map((threadObject) => {
const channel = ChatChannel.create(threadObject.channel);
const thread = ChatThread.create(channel, threadObject);
const tracking = result.tracking[thread.id];
if (tracking) {
thread.tracking.mentionCount = tracking.mention_count;
thread.tracking.unreadCount = tracking.unread_count;
}
return thread;
});
}
<template>
<div class="chat__user-threads-container">
<div class="chat__user-threads" {{this.fill}}>
{{#each this.threadsCollection.items as |thread|}}
<div
class="chat__user-threads__thread-container"
data-id={{thread.id}}
>
<div class="chat__user-threads__thread">
<div class="chat__user-threads__title">
<ThreadTitle @thread={{thread}} />
<ChannelTitle @channel={{thread.channel}} />
</div>
<div class="chat__user-threads__thread-indicator">
<ThreadIndicator
@message={{thread.originalMessage}}
@interactiveUser={{false}}
/>
</div>
</div>
</div>
{{/each}}
<div {{this.loadMore}}>
<br />
</div>
<ConditionalLoadingSpinner
@condition={{this.threadsCollection.loading}}
/>
</div>
</div>
</template>
}

View File

@ -42,6 +42,59 @@ export default {
});
withPluginApi("1.3.0", (api) => {
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
const SidebarChatMyThreadsSectionLink = class extends BaseCustomSidebarSectionLink {
route = "chat.threads";
text = I18n.t("chat.my_threads.title");
title = I18n.t("chat.my_threads.title");
name = "user-threads";
prefixType = "icon";
prefixValue = "discourse-threads";
suffixType = "icon";
suffixCSSClass = "unread";
constructor() {
super(...arguments);
if (container.isDestroyed) {
return;
}
this.chatChannelsManager = container.lookup(
"service:chat-channels-manager"
);
}
get suffixValue() {
return this.chatChannelsManager.publicMessageChannels.some(
(channel) => channel.unreadThreadsCount > 0
)
? "circle"
: "";
}
};
const SidebarChatMyThreadsSection = class extends BaseCustomSidebarSection {
// we only show `My Threads` link
hideSectionHeader = true;
name = "user-threads";
// sidebar API doesn’t let you have undefined values
// even if you don't show the section’s header
title = "";
get links() {
return [new SidebarChatMyThreadsSectionLink()];
}
};
return SidebarChatMyThreadsSection;
},
CHAT_PANEL
);
if (this.siteSettings.enable_public_channels) {
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {

View File

@ -110,6 +110,10 @@ export default class ChatChannel {
).length;
}
get unreadThreadsCount() {
return Array.from(this.threadsManager.unreadThreadOverview.values()).length;
}
updateLastViewedAt() {
this.currentUserMembership.lastViewedAt = new Date();
}

View File

@ -45,6 +45,10 @@ export default class ChatThread {
? ChatMessage.create(channel, args.original_message)
: null;
if (this.originalMessage) {
this.originalMessage.thread = this;
}
this.title = args.title;
if (args.current_user_membership) {

View File

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

View File

@ -28,6 +28,7 @@ export default class ChatRoute extends DiscourseRoute {
const INTERCEPTABLE_ROUTES = [
"chat.channel",
"chat.threads",
"chat.channel.thread",
"chat.channel.thread.index",
"chat.channel.thread.near-message",

View File

@ -273,7 +273,7 @@ export default class ChatApi extends Service {
* @returns {Promise}
*/
listCurrentUserChannels() {
return this.#getRequest("/channels/me");
return this.#getRequest("/me/channels");
}
/**
@ -308,6 +308,15 @@ export default class ChatApi extends Service {
return this.#deleteRequest(`/channels/${channelId}/memberships/me`);
}
/**
* Get the list of tracked threads for the current user.
*
* @returns {Promise}
*/
userThreads(handler) {
return new Collection(`${this.#basePath}/me/threads`, handler);
}
/**
* Update notifications settings of current user for a channel.
* @param {number} channelId - The ID of the channel.

View File

@ -1,6 +1,7 @@
import { tracked } from "@glimmer/tracking";
import Service, { inject as service } from "@ember/service";
import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel";
import ChatDrawerChannelThreads from "discourse/plugins/chat/discourse/components/chat-drawer/channel-threads";
import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index";
import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread";
import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads";
@ -36,13 +37,16 @@ const ROUTES = {
},
},
"chat.channel.threads": {
name: ChatDrawerThreads,
name: ChatDrawerChannelThreads,
extractParams: (route) => {
return {
channelId: route.parent.params.channelId,
};
},
},
"chat.threads": {
name: ChatDrawerThreads,
},
chat: { name: ChatDrawerIndex },
"chat.channel.near-message": {
name: ChatDrawerChannel,

View File

@ -168,7 +168,7 @@
{{/if}}
</div>
<div><ChatChannelTitle @channel={{webhook.chat_channel}} /></div>
<div><ChannelTitle @channel={{webhook.chat_channel}} /></div>
<div>{{webhook.description}}</div>
</div>

View File

@ -0,0 +1 @@
<Chat::Threads />