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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 1419 additions and 227 deletions

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Chat::Api::CurrentUserThreadsController < Chat::ApiController
def index
with_service(::Chat::LookupUserThreads) do
on_success do
render_serialized(
::Chat::ThreadsView.new(
user: guardian.user,
threads: result.threads,
channel: result.channel,
tracking: result.tracking,
memberships: result.memberships,
load_more_url: result.load_more_url,
threads_participants: result.participants,
),
::Chat::ThreadListSerializer,
root: false,
)
end
on_model_not_found(:threads) { render json: success_json.merge(threads: []) }
end
end
end

View File

@ -11,6 +11,7 @@ module Chat
scope: scope,
membership: object.memberships.find { |m| m.thread_id == thread.id },
include_thread_preview: true,
include_channel: true,
include_thread_original_message: true,
participants: object.threads_participants[thread.id],
root: nil,
@ -23,7 +24,9 @@ module Chat
end
def meta
{ channel_id: object.channel.id, load_more_url: object.load_more_url }
meta = { load_more_url: object.load_more_url }
meta[:channel_id] = object.channel.id if object.channel
meta
end
end
end

View File

@ -8,6 +8,7 @@ module Chat
:title,
:status,
:channel_id,
:channel,
:meta,
:reply_count,
:current_user_membership,
@ -22,6 +23,14 @@ module Chat
@current_user_membership = opts[:membership]
end
def include_channel?
@options[:include_channel].presence || false
end
def channel
::Chat::ChannelSerializer.new(object.channel, scope: scope, root: false)
end
def include_original_message?
@opts[:include_thread_original_message].presence || true
end

View File

@ -69,7 +69,6 @@ module Chat
def fetch_threads(guardian:, channel:, **)
::Chat::Thread
.strict_loading
.includes(
:channel,
:user_chat_thread_memberships,
@ -93,28 +92,26 @@ module Chat
user: :user_status,
],
)
.joins(:user_chat_thread_memberships, :original_message)
.joins(
"LEFT JOIN chat_messages AS last_message ON last_message.id = chat_threads.last_message_id",
"LEFT JOIN user_chat_thread_memberships ON chat_threads.id = user_chat_thread_memberships.thread_id AND user_chat_thread_memberships.user_id = #{guardian.user.id} AND user_chat_thread_memberships.notification_level NOT IN (#{::Chat::UserChatThreadMembership.notification_levels[:muted]})",
)
.where("user_chat_thread_memberships.user_id = ?", guardian.user.id)
.where(
"user_chat_thread_memberships.notification_level IN (?)",
[
::Chat::UserChatThreadMembership.notification_levels[:normal],
::Chat::UserChatThreadMembership.notification_levels[:tracking],
],
.joins(
"LEFT JOIN chat_messages AS last_message ON chat_threads.last_message_id = last_message.id",
)
.where("chat_threads.channel_id = ?", channel.id)
.joins(
"INNER JOIN chat_messages AS original_message ON chat_threads.original_message_id = original_message.id",
)
.where(channel_id: channel.id)
.where("original_message.chat_channel_id = chat_threads.channel_id")
.where("original_message.deleted_at IS NULL")
.where("last_message.chat_channel_id = chat_threads.channel_id")
.where("last_message.deleted_at IS NULL")
.where("chat_threads.replies_count > 0")
.order(
"CASE WHEN user_chat_thread_memberships.last_read_message_id IS NULL OR user_chat_thread_memberships.last_read_message_id < chat_threads.last_message_id THEN true ELSE false END DESC, last_message.created_at DESC",
)
.limit(context.limit)
.offset(context.offset)
.order(
"CASE WHEN (
chat_threads.last_message_id > user_chat_thread_memberships.last_read_message_id OR
user_chat_thread_memberships.last_read_message_id IS NULL
) THEN 0 ELSE 1 END, last_message.created_at DESC",
)
end
def fetch_tracking(guardian:, threads:, **)

View File

@ -0,0 +1,145 @@
# frozen_string_literal: true
module Chat
# Gets a list of threads for a user.
#
# Only threads that the user is a member of with a notification level
# of normal or tracking will be returned.
#
# @example
# Chat::LookupUserThreads.call(guardian: guardian, limit: 5, offset: 2)
#
class LookupUserThreads
include Service::Base
THREADS_LIMIT = 10
# @!method call(guardian:, limit: nil, offset: nil)
# @param [Guardian] guardian
# @param [Integer] limit
# @param [Integer] offset
# @return [Service::Base::Context]
contract
step :set_limit
step :set_offset
model :threads
step :fetch_tracking
step :fetch_memberships
step :fetch_participants
step :build_load_more_url
# @!visibility private
class Contract
attribute :limit, :integer
attribute :offset, :integer
end
private
def set_limit(contract:, **)
context.limit = (contract.limit || THREADS_LIMIT).to_i.clamp(1, THREADS_LIMIT)
end
def set_offset(contract:, **)
context.offset = [contract.offset || 0, 0].max
end
def fetch_threads(guardian:, **)
::Chat::Thread
.includes(
:channel,
:user_chat_thread_memberships,
original_message_user: :user_status,
last_message: [
:uploads,
:chat_webhook_event,
:chat_channel,
chat_mentions: {
user: :user_status,
},
user: :user_status,
],
original_message: [
:uploads,
:chat_webhook_event,
:chat_channel,
chat_mentions: {
user: :user_status,
},
user: :user_status,
],
)
.joins(
"INNER JOIN user_chat_thread_memberships ON chat_threads.id = user_chat_thread_memberships.thread_id",
)
.joins(
"LEFT JOIN chat_messages AS last_message ON chat_threads.last_message_id = last_message.id",
)
.joins(
"INNER JOIN chat_messages AS original_message ON chat_threads.original_message_id = original_message.id",
)
.where(
channel_id:
::Chat::Channel
.joins(:user_chat_channel_memberships)
.where(user_chat_channel_memberships: { user_id: guardian.user.id, following: true })
.where.not("user_chat_channel_memberships.muted")
.where(
{
chatable_type: "Category",
threading_enabled: true,
status: ::Chat::Channel.statuses[:open],
},
)
.select(:id),
)
.where("original_message.chat_channel_id = chat_threads.channel_id")
.where("original_message.deleted_at IS NULL")
.where("last_message.chat_channel_id = chat_threads.channel_id")
.where("last_message.deleted_at IS NULL")
.where("chat_threads.replies_count > 0")
.where("user_chat_thread_memberships.user_id = ?", guardian.user.id)
.where(
"user_chat_thread_memberships.notification_level IN (?)",
[
::Chat::UserChatThreadMembership.notification_levels[:normal],
::Chat::UserChatThreadMembership.notification_levels[:tracking],
],
)
.order(
"CASE WHEN user_chat_thread_memberships.last_read_message_id IS NULL OR user_chat_thread_memberships.last_read_message_id < chat_threads.last_message_id THEN true ELSE false END DESC, last_message.created_at DESC",
)
.limit(context.limit)
.offset(context.offset)
end
def fetch_tracking(guardian:, threads:, **)
context.tracking =
::Chat::TrackingStateReportQuery.call(
guardian: guardian,
thread_ids: threads.map(&:id),
include_threads: true,
).thread_tracking
end
def fetch_memberships(guardian:, threads:, **)
context.memberships =
::Chat::UserChatThreadMembership.where(
thread_id: threads.map(&:id),
user_id: guardian.user.id,
)
end
def fetch_participants(threads:, **)
context.participants = ::Chat::ThreadParticipantQuery.call(thread_ids: threads.map(&:id))
end
def build_load_more_url(contract:, **)
load_more_params = { limit: context.limit, offset: context.offset + context.limit }.to_query
context.load_more_url =
::URI::HTTP.build(path: "/chat/api/me/threads", query: load_more_params).request_uri
end
end
end

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 doesnt let you have undefined values
// even if you don't show the sections 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 />

View File

@ -198,7 +198,6 @@ body.has-full-page-chat {
background: var(--d-content-background);
.chat-full-page-header {
border-top: 1px solid var(--primary-low);
border-bottom: 1px solid var(--primary-low);
background: var(--secondary);
z-index: 3;
@ -363,7 +362,6 @@ html.has-full-page-chat {
.main-chat-outlet {
min-height: 0;
overflow: hidden;
}
}
}

View File

@ -42,6 +42,10 @@
}
}
.d-icon {
position: unset;
}
.d-icon-lock {
margin-right: 0.25em;
}

View File

@ -260,9 +260,12 @@ a.chat-drawer-header__title {
}
.chat-drawer-content {
@include chat-scrollbar();
box-sizing: border-box;
height: 100%;
min-height: 1px;
padding-bottom: 0.25em;
position: relative;
overflow-y: auto;
overscroll-behavior: contain;
}

View File

@ -0,0 +1,23 @@
.chat-navbar {
flex-shrink: 0;
display: flex;
align-items: center;
width: 100%;
&-container {
padding-inline: 1rem;
position: sticky;
top: var(--header-offset);
border-bottom: 1px solid var(--primary-low);
background: var(--secondary);
height: 50px;
box-sizing: border-box;
display: flex;
z-index: z("header") - 1;
}
}
.chat-navbar__right-actions {
list-style: none;
margin-left: auto;
}

View File

@ -120,11 +120,6 @@
&__unread-indicator {
flex: 0 0 auto;
.chat-thread-list-item-unread-indicator__number {
color: var(--primary);
font-size: var(--font-up-1);
}
}
&__open-button {

View File

@ -0,0 +1,7 @@
.chat__thread-title {
display: flex;
.chat-thread-list-item-unread-indicator {
margin-left: 0.5rem;
}
}

View File

@ -9,4 +9,5 @@
padding: 0.21em 0.42em;
border-radius: 1em;
min-width: 0.6em;
align-self: center;
}

View File

@ -21,3 +21,7 @@
line-height: var(--line-height-small);
}
}
.chat__unread-indicator {
@include chat-unread-indicator;
}

View File

@ -17,7 +17,6 @@
}
&__container {
position: relative;
padding: 1px; // for is-online box-shadow effect, preventing cutoff
.avatar {

View File

@ -0,0 +1,32 @@
.chat__user-threads-row {
display: flex;
align-items: center;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.chat__unread-indicator {
width: 8px;
height: 8px;
}
&-container {
height: 3.6em;
padding: 0 0.5rem;
margin: 0.5rem 0rem 0 0.5rem;
box-sizing: border-box;
display: flex;
&:hover {
background: var(--primary-low);
}
.chat__user-threads-row__icon {
color: var(--primary);
}
.chat__user-threads-row__title {
color: var(--primary);
}
}
}

View File

@ -0,0 +1,23 @@
.chat__user-threads__thread-indicator {
padding-top: 1rem;
.chat-message-thread-indicator {
margin: 0;
}
}
.chat__user-threads__title {
.chat-channel-title__name {
font-size: var(--font-down-1);
color: var(--primary-medium);
}
}
.chat__user-threads__thread {
padding-bottom: 1rem;
border-bottom: 1px solid var(--primary-low);
&-container {
padding: 1rem 1rem 0 1rem;
}
}

View File

@ -65,3 +65,7 @@
@import "chat-channel-row";
@import "chat-channel-members";
@import "chat-channel-settings";
@import "chat-user-threads";
@import "chat-navbar";
@import "chat-user-threads-row";
@import "chat-thread-title";

View File

@ -35,6 +35,12 @@
}
}
.sidebar-section-wrapper.sidebar-section[data-section-name="my-threads"] {
.sidebar-section-content {
padding-bottom: 0;
}
}
.sidebar-container {
.channels-list {
color: var(--primary);

View File

@ -16,7 +16,7 @@
"avatar info info participants"
"excerpt excerpt excerpt replies";
&__replies-count {
align-self: flex-start;
align-self: center;
grid-area: replies;
justify-content: flex-end;
}

View File

@ -491,6 +491,9 @@ en:
create_export: "Create export"
export_has_started: "The export has started. You'll receive a PM when it's ready."
my_threads:
title: My threads
direct_messages:
title: "Personal chat"
new: "Create a personal chat"

View File

@ -4,7 +4,8 @@ Chat::Engine.routes.draw do
namespace :api, defaults: { format: :json } do
get "/chatables" => "chatables#index"
get "/channels" => "channels#index"
get "/channels/me" => "current_user_channels#index"
get "/me/channels" => "current_user_channels#index"
get "/me/threads" => "current_user_threads#index"
post "/channels" => "channels#create"
put "/channels/read/" => "reads#update_all"
put "/channels/:channel_id/read/:message_id" => "reads#update"
@ -72,6 +73,7 @@ Chat::Engine.routes.draw do
# chat_controller routes
get "/" => "chat#respond"
get "/threads" => "chat#respond"
get "/browse" => "chat#respond"
get "/browse/all" => "chat#respond"
get "/browse/closed" => "chat#respond"

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AddUserChatThreadMembershipsOnThreadIdUserIdIndex < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def up
execute <<~SQL
DROP INDEX CONCURRENTLY IF EXISTS idx_user_chat_thread_memberships_on_thread_id_user_id
SQL
execute <<~SQL
CREATE INDEX CONCURRENTLY idx_user_chat_thread_memberships_on_thread_id_user_id
ON user_chat_thread_memberships (thread_id, user_id);
SQL
end
def down
execute <<~SQL
DROP INDEX CONCURRENTLY IF EXISTS idx_user_chat_thread_memberships_on_thread_id_user_id
SQL
end
end

View File

@ -220,12 +220,16 @@ Fabricator(:chat_thread, class_name: "Chat::Thread") do
thread.add(thread.original_message_user)
if transients[:with_replies]
Fabricate.times(
transients[:with_replies],
:chat_message,
thread: thread,
use_service: transients[:use_service],
)
Fabricate
.times(
transients[:with_replies],
:chat_message,
thread: thread,
use_service: transients[:use_service],
)
.each { |message| thread.add(message.user) }
thread.update!(replies_count: transients[:with_replies])
end
end
end

View File

@ -84,9 +84,9 @@ RSpec.describe Chat::Api::ChannelThreadsController do
end
describe "index" do
fab!(:thread_1) { Fabricate(:chat_thread, channel: public_channel) }
fab!(:thread_2) { Fabricate(:chat_thread, channel: public_channel) }
fab!(:thread_3) { Fabricate(:chat_thread, channel: public_channel) }
fab!(:thread_1) { Fabricate(:chat_thread, channel: public_channel, with_replies: 1) }
fab!(:thread_2) { Fabricate(:chat_thread, channel: public_channel, with_replies: 1) }
fab!(:thread_3) { Fabricate(:chat_thread, channel: public_channel, with_replies: 1) }
fab!(:message_1) do
Fabricate(
:chat_message,
@ -111,11 +111,11 @@ RSpec.describe Chat::Api::ChannelThreadsController do
thread_3.add(current_user)
end
it "returns the threads the user has sent messages in for the channel" do
it "returns the threads of the channel" do
get "/chat/api/channels/#{public_channel.id}/threads"
expect(response.status).to eq(200)
expect(response.parsed_body["threads"].map { |thread| thread["id"] }).to eq(
[thread_3.id, thread_1.id],
[thread_3.id, thread_2.id, thread_1.id],
)
end

View File

@ -13,7 +13,7 @@ describe Chat::Api::CurrentUserChannelsController do
describe "#index" do
context "as anonymous user" do
it "returns an error" do
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.status).to eq(403)
end
end
@ -25,7 +25,7 @@ describe Chat::Api::CurrentUserChannelsController do
end
it "returns an error" do
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.status).to eq(403)
end
@ -39,7 +39,7 @@ describe Chat::Api::CurrentUserChannelsController do
it "returns public channels with memberships" do
channel = Fabricate(:category_channel)
channel.add(current_user)
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.parsed_body["public_channels"][0]["id"]).to eq(channel.id)
end
@ -49,7 +49,7 @@ describe Chat::Api::CurrentUserChannelsController do
channel = Fabricate(:private_category_channel, group: group)
group.add(current_user)
channel.add(current_user)
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.parsed_body["public_channels"][0]["id"]).to eq(channel.id)
end
@ -58,21 +58,21 @@ describe Chat::Api::CurrentUserChannelsController do
group = Fabricate(:group)
channel = Fabricate(:private_category_channel, group: group)
channel.add(current_user) # TODO: we should error here
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.parsed_body["public_channels"]).to be_blank
end
it "returns dm channels you are part of" do
dm_channel = Fabricate(:direct_message_channel, users: [current_user])
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_channel.id)
end
it "doesnt return dm channels from other users" do
Fabricate(:direct_message_channel)
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.parsed_body["direct_message_channels"]).to be_blank
end
@ -81,7 +81,7 @@ describe Chat::Api::CurrentUserChannelsController do
Fabricate(:direct_message_channel, users: [current_user])
channel = Fabricate(:category_channel)
channel.add(current_user)
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.status).to eq(200)
@ -112,7 +112,7 @@ describe Chat::Api::CurrentUserChannelsController do
channel = Fabricate(:category_channel)
channel.add(current_user)
channel.chatable.destroy!
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.status).to eq(200)
expect(response.parsed_body["public_channels"]).to be_blank
@ -123,7 +123,7 @@ describe Chat::Api::CurrentUserChannelsController do
it "doesnt return the channel" do
channel = Fabricate(:direct_message_channel, users: [current_user])
channel.chatable.destroy!
get "/chat/api/channels/me"
get "/chat/api/me/channels"
expect(response.status).to eq(200)
expect(response.parsed_body["direct_message_channels"]).to be_blank

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
require "rails_helper"
describe Chat::Api::CurrentUserThreadsController do
fab!(:current_user) { Fabricate(:user) }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
sign_in(current_user)
end
describe "#index" do
describe "success" do
it "works" do
get "/chat/api/me/threads"
expect(response.status).to eq(200)
end
end
context "when threads are not found" do
it "returns a 200 with empty threads" do
get "/chat/api/me/threads"
expect(response.status).to eq(200)
expect(response.parsed_body["threads"]).to eq([])
end
end
end
end

View File

@ -113,11 +113,14 @@ RSpec.describe ::Chat::LookupChannelThreads do
before do
[thread_1, thread_2, thread_3].each.with_index do |t, index|
t.original_message.update!(created_at: (index + 1).weeks.ago)
t.update!(replies_count: 2)
t.add(current_user)
end
end
describe "model - threads" do
before { channel_1.add(current_user) }
it { is_expected.to be_a_success }
it "orders threads by the last reply created_at timestamp" do
@ -153,9 +156,13 @@ RSpec.describe ::Chat::LookupChannelThreads do
it "sorts very old unreads to top over recency, and sorts both unreads and other threads by recency" do
thread_4 = Fabricate(:chat_thread, channel: channel_1)
thread_4.update!(replies_count: 2)
thread_5 = Fabricate(:chat_thread, channel: channel_1)
thread_5.update!(replies_count: 2)
thread_6 = Fabricate(:chat_thread, channel: channel_1)
thread_6.update!(replies_count: 2)
thread_7 = Fabricate(:chat_thread, channel: channel_1)
thread_7.update!(replies_count: 2)
[thread_4, thread_5, thread_6, thread_7].each do |t|
t.add(current_user)
@ -202,15 +209,23 @@ RSpec.describe ::Chat::LookupChannelThreads do
expect(result.threads.map(&:id)).to eq([thread_1.id, thread_2.id, thread_3.id])
end
it "only returns threads where the user has their thread notification level as tracking or regular" do
it "returns every threads of the channel, no matter the tracking notification level or membership" do
thread_4 = Fabricate(:chat_thread, channel: channel_1)
thread_4.add(current_user)
thread_4.membership_for(current_user).update!(
thread_4.update!(replies_count: 2)
expect(result.threads.map(&:id)).to match_array(
[thread_1.id, thread_2.id, thread_3.id, thread_4.id],
)
end
it "doesnt return muted threads" do
thread = Fabricate(:chat_thread, channel: channel_1)
thread.add(current_user)
thread.membership_for(current_user).update!(
notification_level: ::Chat::UserChatThreadMembership.notification_levels[:muted],
)
Fabricate(:chat_thread, channel: channel_1)
expect(result.threads.map(&:id)).to eq([thread_1.id, thread_2.id, thread_3.id])
expect(result.threads.map(&:id)).to_not include(thread.id)
end
it "does not count deleted messages for sort order" do

View File

@ -0,0 +1,171 @@
# frozen_string_literal: true
RSpec.describe ::Chat::LookupUserThreads do
subject(:result) { described_class.call(params) }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
let(:guardian) { Guardian.new(current_user) }
let(:channel_id) { channel_1.id }
let(:limit) { 10 }
let(:offset) { 0 }
let(:params) { { guardian: guardian, limit: limit, offset: offset } }
before { channel_1.add(current_user) }
context "when all steps pass" do
it "returns threads" do
thread_1 =
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.threads).to eq([thread_1])
end
it "limits results by default" do
Fabricate
.times(11, :chat_thread, channel: channel_1, with_replies: 1)
.each { |thread| thread.add(current_user) }
expect(result.threads.length).to eq(10)
end
it "can limit results" do
params[:limit] = 1
Fabricate
.times(2, :chat_thread, channel: channel_1, with_replies: 1)
.each { |thread| thread.add(current_user) }
expect(result.threads.length).to eq(params[:limit])
end
it "limits to 1 at least" do
params[:limit] = 0
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.threads.length).to eq(1)
end
it "has a max limit" do
params[:limit] = 11
Fabricate
.times(11, :chat_thread, channel: channel_1, with_replies: 1)
.each { |thread| thread.add(current_user) }
expect(result.threads.length).to eq(10)
end
it "can offset" do
params[:offset] = 1
threads =
Fabricate
.times(2, :chat_thread, channel: channel_1, with_replies: 1)
.each { |thread| thread.add(current_user) }
# 0 because we sort by last_message.created_at, so the last created thread is the first one
expect(result.threads).to eq([threads[0]])
end
it "has a min offset" do
params[:offset] = -99
threads =
Fabricate
.times(2, :chat_thread, channel: channel_1, with_replies: 1)
.each { |thread| thread.add(current_user) }
# 0 because we sort by last_message.created_at, so the last created thread is the first one
expect(result.threads.length).to eq(2)
end
it "fetches tracking" do
thread_1 =
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.tracking).to eq(
::Chat::TrackingStateReportQuery.call(
guardian: current_user.guardian,
thread_ids: [thread_1.id],
include_threads: true,
).thread_tracking,
)
end
it "fetches memberships" do
thread_1 =
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.memberships).to eq([thread_1.membership_for(current_user)])
end
it "fetches participants" do
thread_1 =
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.participants).to eq(
::Chat::ThreadParticipantQuery.call(thread_ids: [thread_1.id]),
)
end
it "builds a load_more_url" do
Fabricate(:chat_thread, channel: channel_1, with_replies: 1).tap do |thread|
thread.add(current_user)
end
expect(result.load_more_url).to eq("/chat/api/me/threads?limit=10&offset=10")
end
end
it "doesn't return threads with no replies" do
thread_1 = Fabricate(:chat_thread, channel: channel_1)
thread_1.add(current_user)
expect(result.threads).to eq([])
end
it "doesn't return threads with no membership" do
thread_1 = Fabricate(:chat_thread, channel: channel_1, with_replies: 1)
expect(result.threads).to eq([])
end
it "doesn't return threads when the channel has not threading enabled" do
channel_1.update!(threading_enabled: false)
thread_1 = Fabricate(:chat_thread, channel: channel_1, with_replies: 1)
thread_1.add(current_user)
expect(result.threads).to eq([])
end
it "doesn't return muted threads" do
thread_1 = Fabricate(:chat_thread, channel: channel_1, with_replies: 1)
thread_1.add(current_user)
thread_1.membership_for(current_user).update!(
notification_level: ::Chat::UserChatThreadMembership.notification_levels[:muted],
)
expect(result.threads).to eq([])
end
it "doesn't return threads when the channel it not open" do
channel_1.update!(status: Chat::Channel.statuses[:closed])
thread_1 = Fabricate(:chat_thread, channel: channel_1, with_replies: 1)
thread_1.add(current_user)
expect(result.threads).to eq([])
end
end

View File

@ -141,7 +141,7 @@ RSpec.describe "Navigation", type: :system do
thread_list_page.open_thread(thread)
expect(side_panel_page).to have_open_thread(thread)
expect(thread_page).to have_back_link_to_thread_list(category_channel)
thread_page.back_to_previous_route
thread_page.back
expect(page).to have_current_path("#{category_channel.relative_url}/t")
expect(thread_list_page).to have_loaded
end
@ -157,7 +157,7 @@ RSpec.describe "Navigation", type: :system do
thread_list_page.open_thread(thread)
expect(side_panel_page).to have_open_thread(thread)
expect(thread_page).to have_back_link_to_thread_list(category_channel)
thread_page.back_to_previous_route
thread_page.back
expect(page).to have_current_path("#{category_channel.relative_url}/t")
expect(thread_list_page).to have_loaded
end
@ -173,7 +173,7 @@ RSpec.describe "Navigation", type: :system do
channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel_page).to have_open_thread(thread)
expect(thread_page).to have_back_link_to_thread_list(category_channel)
thread_page.back_to_previous_route
thread_page.back
expect(page).to have_current_path("#{category_channel.relative_url}/t")
expect(thread_list_page).to have_loaded
end
@ -188,7 +188,7 @@ RSpec.describe "Navigation", type: :system do
channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel_page).to have_open_thread(thread)
expect(thread_page).to have_back_link_to_channel(category_channel)
thread_page.back_to_previous_route
thread_page.back
expect(page).to have_current_path("#{category_channel.relative_url}")
expect(side_panel_page).to be_closed
end

View File

@ -53,6 +53,11 @@ module PageObjects
has_finished_loading?
end
def visit_user_threads
visit("/chat/threads")
has_no_css?(".spinner")
end
def visit_thread(thread)
visit(thread.url)
has_css?(".chat-thread:not(.loading)[data-id=\"#{thread.id}\"]")

View File

@ -68,7 +68,7 @@ module PageObjects
header.has_css?(".chat-thread__back-to-previous-route[href='#{channel.relative_url}']")
end
def back_to_previous_route
def back
header.find(".chat-thread__back-to-previous-route").click
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module PageObjects
module Pages
class UserThreads < PageObjects::Pages::Base
def has_threads?(count: nil)
has_no_css?(".spinner")
has_css?(".chat__user-threads__thread-container", count: count)
end
def open_thread(thread)
find(".chat__user-threads__thread-container[data-id='#{thread.id}']").click
end
end
end
end

View File

@ -37,6 +37,22 @@ module PageObjects
channel_index.has_no_unread_channel?(channel)
end
def has_user_threads_section?
has_css?(".chat__user-threads-row-container[href='/chat/threads']")
end
def has_unread_user_threads?
has_css?(".chat__user-threads-row .chat__unread-indicator")
end
def has_no_unread_user_threads?
has_no_css?(".chat__user-threads-row .chat__unread-indicator")
end
def click_user_threads
find(".chat__user-threads-row").click
end
def maximize
mouseout
find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click

View File

@ -37,6 +37,25 @@ module PageObjects
self
end
def has_user_threads_section?
has_css?(
".sidebar-section-link[data-link-name='user-threads'][href='/chat/threads']",
text: I18n.t("js.chat.my_threads.title"),
)
end
def has_unread_user_threads?
has_css?(
".sidebar-section-link[data-link-name='user-threads'] .sidebar-section-link-suffix.icon.unread",
)
end
def has_no_unread_user_threads?
has_no_css?(
".sidebar-section-link[data-link-name='user-threads'] .sidebar-section-link-suffix.icon.unread",
)
end
def has_unread_channel?(channel)
has_css?(".sidebar-section-link.channel-#{channel.id} .sidebar-section-link-suffix.unread")
end

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.back_to_previous_route
thread_page.back
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.back_to_previous_route
thread_page.back
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")

View File

@ -30,19 +30,19 @@ describe "Thread list in side panel | full page", type: :system do
before { chat_system_user_bootstrap(user: other_user, channel: channel) }
it "does not show existing threads in the channel if the user is not tracking them" do
Fabricate(:chat_thread, original_message: thread_om, channel: channel, use_service: true)
it "it shows threads in the channel even if the user is not tracking them" do
thread_1 =
Fabricate(
:chat_thread,
original_message: thread_om,
channel: channel,
with_replies: 1,
use_service: true,
)
chat_page.visit_channel(channel)
channel_page.open_thread_list
expect(page).to have_content(I18n.t("js.chat.threads.none"))
end
it "does not show new threads in the channel in the thread list if the user is not tracking them" do
chat_page.visit_channel(channel)
Fabricate(:chat_message, chat_channel: channel, in_reply_to: thread_om, use_service: true)
channel_page.open_thread_list
expect(page).to have_content(I18n.t("js.chat.threads.none"))
expect(thread_list_page).to have_thread(thread_1)
end
describe "when the user creates a new thread" do

View File

@ -49,7 +49,7 @@ describe "Thread tracking state | full page", type: :system do
expect(thread_page).to have_no_unread_list_indicator
thread_page.back_to_previous_route
thread_page.back
expect(thread_list_page).to have_no_unread_item(thread.id)
end

View File

@ -0,0 +1,194 @@
# frozen_string_literal: true
RSpec.describe "User threads", type: :system do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:sidebar_page) { PageObjects::Pages::Sidebar.new }
let(:drawer_page) { PageObjects::Pages::ChatDrawer.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:user_threads_page) { PageObjects::Pages::UserThreads.new }
before do
chat_system_bootstrap
sign_in(current_user)
end
context "when in sidebar" do
it "shows a link to user threads" do
visit("/")
expect(sidebar_page).to have_user_threads_section
end
context "when user has unreads" do
before do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
end
xit "has an unread indicator" do
visit("/")
expect(sidebar_page).to have_unread_user_threads
end
end
it "has no unread indicator when user has no unreads" do
visit("/")
expect(sidebar_page).to have_no_unread_user_threads
end
it "lists threads" do
Fabricate
.times(5, :chat_channel, threading_enabled: true)
.each do |channel|
chat_thread_chain_bootstrap(
channel: channel,
users: [current_user, Fabricate(:user)],
messages_count: 2,
)
end
chat_page.visit_user_threads
expect(user_threads_page).to have_threads(count: 5)
end
it "can load more threads" do
Fabricate
.times(20, :chat_channel, threading_enabled: true)
.each do |channel|
chat_thread_chain_bootstrap(
channel: channel,
users: [current_user, Fabricate(:user)],
messages_count: 2,
)
end
chat_page.visit_user_threads
expect(user_threads_page).to have_threads(count: 10)
page.execute_script("window.scrollTo(0, document.body.scrollHeight)")
expect(user_threads_page).to have_threads(count: 20)
end
it "can open a thread" do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
chat_page.visit_user_threads
user_threads_page.open_thread(channel_1.threads.first)
expect(chat_page).to have_current_path(
"/chat/c/#{channel_1.slug}/#{channel_1.id}/t/#{channel_1.threads.first.id} ",
)
end
it "navigating back from a thread opens the user threads" do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
chat_page.visit_user_threads
user_threads_page.open_thread(channel_1.threads.first)
thread_page.back
expect(user_threads_page).to have_threads
end
end
context "when in drawer" do
it "shows a link to user threads" do
visit("/")
chat_page.open_from_header
expect(drawer_page).to have_user_threads_section
end
context "when user has unreads" do
before do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
end
xit "has an unread indicator" do
visit("/")
chat_page.open_from_header
expect(drawer_page).to have_unread_user_threads
end
end
it "has no unread indicator when user has no unreads" do
visit("/")
expect(sidebar_page).to have_no_unread_user_threads
end
it "lists threads" do
Fabricate
.times(5, :chat_channel, threading_enabled: true)
.each do |channel|
chat_thread_chain_bootstrap(
channel: channel,
users: [current_user, Fabricate(:user)],
messages_count: 2,
)
end
visit("/")
chat_page.open_from_header
drawer_page.click_user_threads
expect(user_threads_page).to have_threads(count: 5)
end
it "can load more threads" do
Fabricate
.times(20, :chat_channel, threading_enabled: true)
.each do |channel|
chat_thread_chain_bootstrap(
channel: channel,
users: [current_user, Fabricate(:user)],
messages_count: 2,
)
end
visit("/")
chat_page.open_from_header
drawer_page.click_user_threads
expect(user_threads_page).to have_threads(count: 10)
page.execute_script(
"document.querySelector('.chat-drawer-content').scrollTo(0, document.querySelector('.chat-drawer-content').scrollHeight)",
)
expect(user_threads_page).to have_threads(count: 20)
end
it "can open a thread" do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
visit("/")
chat_page.open_from_header
drawer_page.click_user_threads
user_threads_page.open_thread(channel_1.threads.first)
expect(drawer_page).to have_open_thread(channel_1.threads.first)
end
it "navigating back from a thread opens the user threads" do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, Fabricate(:user)])
visit("/")
chat_page.open_from_header
drawer_page.click_user_threads
user_threads_page.open_thread(channel_1.threads.first)
drawer_page.back
expect(user_threads_page).to have_threads
end
end
end

View File

@ -6,13 +6,13 @@ import { exists, query } from "discourse/tests/helpers/qunit-helpers";
import fabricators from "discourse/plugins/chat/discourse/lib/fabricators";
import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel";
module("Discourse Chat | Component | chat-channel-title", function (hooks) {
module("Discourse Chat | Component | <ChannelTitle />", function (hooks) {
setupRenderingTest(hooks);
test("category channel", async function (assert) {
this.channel = fabricators.channel();
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
assert.strictEqual(
query(".chat-channel-title__category-badge").getAttribute("style"),
@ -30,7 +30,7 @@ module("Discourse Chat | Component | chat-channel-title", function (hooks) {
title: "<div class='xss'>evil</div>",
});
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
assert.false(exists(".xss"));
});
@ -40,7 +40,7 @@ module("Discourse Chat | Component | chat-channel-title", function (hooks) {
chatable: fabricators.category({ read_restricted: true }),
});
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
assert.true(exists(".d-icon-lock"));
});
@ -50,7 +50,7 @@ module("Discourse Chat | Component | chat-channel-title", function (hooks) {
chatable: fabricators.category({ read_restricted: false }),
});
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
assert.false(exists(".d-icon-lock"));
});
@ -62,7 +62,7 @@ module("Discourse Chat | Component | chat-channel-title", function (hooks) {
}),
});
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
const user = this.channel.chatable.users[0];
@ -79,7 +79,7 @@ module("Discourse Chat | Component | chat-channel-title", function (hooks) {
});
this.channel.chatable.group = true;
await render(hbs`<ChatChannelTitle @channel={{this.channel}} />`);
await render(hbs`<ChannelTitle @channel={{this.channel}} />`);
const users = this.channel.chatable.users;

View File

@ -429,7 +429,7 @@ module(
);
assert.true(
queryAll(".chat-message-collapser-link-small")[1].innerHTML.includes(
"%3Cscript%3Esomeeviltitle%3C/script%3E"
"&lt;script&gt;someeviltitle&lt;/script&gt;"
)
);
});