FEATURE: Chat side panel with threads initial skeleton (#20209)

This commit introduces the skeleton of the chat thread UI. The
structure of the components looks like this. Its done this way
so the side panel can be used for other things as well if we wish,
not just for threads:

```
.main-chat-outlet
   <ChatLivePane />
   <ChatSidePanel>
     <-- rendered with {{outlet}} -->
     <ChatThread />
   </ChatSidePanel>
```

Later on the `ChatThreadList` will be rendered here as well.
Now, when you go to a channel you can open a thread by clicking
on either the Open Thread message action button or by clicking on
the reply indicator. This will take you to a route like `chat/c/:slug/:channelId/t/:threadId`.
This works on mobile as well.

This commit includes basic serializers and routes for threads,
as well as a new `ChatThreadsManager` service in JS that caches
threads for a channel the same way the channel threads manager does.

The chat messages inside the thread are intentionally left out
until a later PR.

**NOTE: These changes are gated behind the site setting enable_experimental_chat_threaded_discussions
and the threading_enabled boolean on a ChatChannel**
This commit is contained in:
Martin Brennan 2023-02-14 11:38:41 +10:00 committed by GitHub
parent cf5fa23cd3
commit 07ab20131a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 721 additions and 38 deletions

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelThreadsController < Chat::Api
def show
params.require(:channel_id)
params.require(:thread_id)
raise Discourse::NotFound if !SiteSetting.enable_experimental_chat_threaded_discussions
thread =
ChatThread
.includes(:channel)
.includes(original_message_user: :user_status)
.includes(original_message: :chat_webhook_event)
.find_by!(id: params[:thread_id], channel_id: params[:channel_id])
guardian.ensure_can_preview_chat_channel!(thread.channel)
render_serialized(thread, ChatThreadSerializer, root: "thread")
end
end

View File

@ -82,7 +82,7 @@ class ChatMessage < ActiveRecord::Base
UploadReference.insert_all!(ref_record_attrs)
end
def excerpt
def excerpt(max_length: 50)
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
@ -90,7 +90,7 @@ class ChatMessage < ActiveRecord::Base
return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(cooked, 50, {})
PrettyText.excerpt(cooked, max_length, { text_entities: true })
end
def cooked_for_excerpt

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class ChatThread < ActiveRecord::Base
EXCERPT_LENGTH = 150
belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel"
belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User"
belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage"
@ -19,6 +21,10 @@ class ChatThread < ActiveRecord::Base
def relative_url
"#{channel.relative_url}/t/#{self.id}"
end
def excerpt
original_message.excerpt(max_length: EXCERPT_LENGTH)
end
end
# == Schema Information

View File

@ -20,7 +20,12 @@ class ChatChannelSerializer < ApplicationSerializer
:archive_topic_id,
:memberships_count,
:current_user_membership,
:meta
:meta,
:threading_enabled
def threading_enabled
SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled
end
def initialize(object, opts)
super(object, opts)

View File

@ -13,7 +13,8 @@ class ChatMessageSerializer < ApplicationSerializer
:edited,
:reactions,
:bookmark,
:available_flags
:available_flags,
:thread_id
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ChatThreadOriginalMessageSerializer < ApplicationSerializer
attributes :id, :created_at, :excerpt, :thread_id
has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects
def excerpt
WordWatcher.censor(object.excerpt(max_length: ChatThread::EXCERPT_LENGTH))
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ChatThreadSerializer < ApplicationSerializer
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects
has_one :original_message, serializer: ChatThreadOriginalMessageSerializer, embed: :objects
attributes :id, :title, :status
end

View File

@ -24,6 +24,7 @@ module ChatPublisher
message_id: chat_message.id,
user_id: chat_message.user.id,
username: chat_message.user.username,
thread_id: chat_message.thread_id,
},
permissions,
)

View File

@ -7,6 +7,7 @@ export default function () {
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
this.route("near-message", { path: "/:messageId" });
this.route("thread", { path: "/t/:threadId" });
});
this.route(

View File

@ -776,8 +776,15 @@ export default Component.extend({
id: data.chat_message.id,
staged_id: null,
excerpt: data.chat_message.excerpt,
thread_id: data.chat_message.thread_id,
});
const inReplyToMsg =
this.messageLookup[data.chat_message.in_reply_to?.id];
if (inReplyToMsg && !inReplyToMsg.thread_id) {
inReplyToMsg.set("thread_id", data.chat_message.thread_id);
}
// some markdown is cooked differently on the server-side, e.g.
// quotes, avatar images etc.
if (

View File

@ -37,6 +37,15 @@
/>
{{/if}}
{{#if this.messageCapabilities.hasThread}}
<DButton
@class="btn-flat chat-message-thread-btn"
@action={{this.messageActions.openThread}}
@icon="puzzle-piece"
@title="chat.threads.open"
/>
{{/if}}
{{#if this.secondaryButtons.length}}
<DropdownSelectBox
@class="more-buttons"

View File

@ -84,7 +84,7 @@
{{#if this.message.in_reply_to}}
<div
role="button"
onclick={{action "viewReply"}}
onclick={{action "viewReplyOrThread"}}
class="chat-reply is-direct-reply"
>
{{d-icon "share" title="chat.in_reply_to"}}

View File

@ -49,6 +49,7 @@ export default Component.extend({
tagName: "",
chat: service(),
dialog: service(),
router: service(),
chatMessageActionsMobileAnchor: null,
chatMessageActionsDesktopAnchor: null,
chatMessageEmojiPickerAnchor: null,
@ -237,6 +238,14 @@ export default Component.extend({
});
}
if (this.hasThread) {
buttons.push({
id: "openThread",
name: I18n.t("chat.threads.open"),
icon: "puzzle-piece",
});
}
return buttons;
},
@ -252,6 +261,7 @@ export default Component.extend({
restore: this.restore,
rebakeMessage: this.rebakeMessage,
toggleBookmark: this.toggleBookmark,
openThread: this.openThread,
startReactionForMessageActions: this.startReactionForMessageActions,
};
},
@ -261,9 +271,15 @@ export default Component.extend({
canReact: this.canReact,
canReply: this.canReply,
canBookmark: this.showBookmarkButton,
hasThread: this.canReply && this.hasThread,
};
},
@discourseComputed("message.thread_id")
hasThread() {
return this.chatChannel.threading_enabled && this.message.thread_id;
},
@discourseComputed("message", "details.can_moderate")
show(message, canModerate) {
return (
@ -678,8 +694,12 @@ export default Component.extend({
},
@action
viewReply() {
this.replyMessageClicked(this.message.in_reply_to);
viewReplyOrThread() {
if (this.hasThread) {
this.router.transitionTo("chat.channel.thread", this.message.thread_id);
} else {
this.replyMessageClicked(this.message.in_reply_to);
}
},
@action
@ -719,6 +739,11 @@ export default Component.extend({
).catch(popupAjaxError);
},
@action
openThread() {
this.router.transitionTo("chat.channel.thread", this.message.thread_id);
},
@action
toggleBookmark() {
return openBookmarkModal(

View File

@ -0,0 +1,5 @@
{{#if this.chatStateManager.isSidePanelExpanded}}
<div class="chat-side-panel">
{{yield}}
</div>
{{/if}}

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatSidePanel extends Component {
@service chatStateManager;
}

View File

@ -0,0 +1,36 @@
<div class="chat-thread" data-id={{this.thread.id}}>
<div class="chat-thread__header">
<div class="chat-thread__info">
<div class="chat-thread__title">
<h2>{{this.title}}</h2>
<LinkTo
class="chat-thread__close"
@route="chat.channel"
@models={{this.chat.activeChannel.routeModels}}
>
{{d-icon "times"}}
</LinkTo>
</div>
<p class="chat-thread__om">
{{replace-emoji this.thread.original_message.excerpt}}
</p>
<div class="chat-thread__omu">
<span class="chat-thread__started-by">{{i18n
"chat.threads.started_by"
}}</span>
<ChatMessageAvatar
class="chat-thread__omu-avatar"
@message={{this.thread.original_message}}
/>
<span
class="chat-thread__omu-username"
>{{this.thread.original_message_user.username}}</span>
</div>
</div>
</div>
<div class="chat-thread__messages">
</div>
</div>

View File

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import { inject as service } from "@ember/service";
export default class ChatThreadPanel extends Component {
@service siteSettings;
@service currentUser;
@service chat;
@service router;
get thread() {
return this.chat.activeThread;
}
get title() {
if (this.thread.title) {
this.thread.escapedTitle;
}
return I18n.t("chat.threads.op_said");
}
}

View File

@ -3,6 +3,8 @@ import { inject as service } from "@ember/service";
export default class ChatController extends Controller {
@service chat;
@service chatStateManager;
@service router;
get shouldUseChatSidebar() {
if (this.site.mobileView) {
@ -19,4 +21,21 @@ export default class ChatController extends Controller {
get shouldUseCoreSidebar() {
return this.siteSettings.navigation_menu === "sidebar";
}
get mainOutletModifierClasses() {
let modifierClasses = [];
if (this.chatStateManager.isSidePanelExpanded) {
modifierClasses.push("has-side-panel-expanded");
}
if (
!this.router.currentRouteName.startsWith("chat.channel.info") &&
!this.router.currentRouteName.startsWith("chat.browse")
) {
modifierClasses.push("chat-view");
}
return modifierClasses.join(" ");
}
}

View File

@ -0,0 +1,31 @@
import RestModel from "discourse/models/rest";
import User from "discourse/models/user";
import { escapeExpression } from "discourse/lib/utilities";
import { tracked } from "@glimmer/tracking";
export const THREAD_STATUSES = {
open: "open",
readOnly: "read_only",
closed: "closed",
archived: "archived",
};
export default class ChatThread extends RestModel {
@tracked title;
@tracked status;
get escapedTitle() {
return escapeExpression(this.title);
}
}
ChatThread.reopenClass({
create(args) {
args = args || {};
if (!args.original_message_user instanceof User) {
args.original_message_user = User.create(args.original_message_user);
}
args.original_message.user = args.original_message_user;
return this._super(args);
},
});

View File

@ -0,0 +1,21 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
export default class ChatChannelThread extends DiscourseRoute {
@service router;
@service chatThreadsManager;
@service chatStateManager;
@service chat;
async model(params) {
return this.chatThreadsManager.find(
this.modelFor("chat.channel").id,
params.threadId
);
}
afterModel(model) {
this.chat.activeThread = model;
this.chatStateManager.openSidePanel();
}
}

View File

@ -1,5 +1,26 @@
import DiscourseRoute from "discourse/routes/discourse";
import withChatChannel from "./chat-channel-decorator";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
@withChatChannel
export default class ChatChannelRoute extends DiscourseRoute {}
export default class ChatChannelRoute extends DiscourseRoute {
@service chatThreadsManager;
@service chatStateManager;
@action
willTransition(transition) {
this.chat.activeThread = null;
this.chatStateManager.closeSidePanel();
if (!transition?.to?.name?.startsWith("chat.")) {
this.chatStateManager.storeChatURL();
this.chat.activeChannel = null;
this.chat.updatePresence();
}
}
beforeModel() {
this.chatThreadsManager.resetCache();
}
}

View File

@ -11,6 +11,7 @@ import Collection from "../lib/collection";
*/
export default class ChatApi extends Service {
@service chatChannelsManager;
@service chatThreadsManager;
/**
* Get a channel by its ID.
@ -27,6 +28,22 @@ export default class ChatApi extends Service {
);
}
/**
* Get a thread in a channel by its ID.
* @param {number} channelId - The ID of the channel.
* @param {number} threadId - The ID of the thread.
* @returns {Promise}
*
* @example
*
* this.chatApi.thread(5, 1).then(thread => { ... })
*/
thread(channelId, threadId) {
return this.#getRequest(`/channels/${channelId}/threads/${threadId}`).then(
(result) => this.chatThreadsManager.store(result.thread)
);
}
/**
* List all accessible category channels of the current user.
* @returns {Collection}

View File

@ -14,6 +14,7 @@ export default class ChatStateManager extends Service {
@service router;
isDrawerExpanded = false;
isDrawerActive = false;
isSidePanelExpanded = false;
@tracked _chatURL = null;
@tracked _appURL = null;
@ -33,6 +34,14 @@ export default class ChatStateManager extends Service {
this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT });
}
openSidePanel() {
this.set("isSidePanelExpanded", true);
}
closeSidePanel() {
this.set("isSidePanelExpanded", false);
}
didOpenDrawer(URL = null) {
this.set("isDrawerActive", true);
this.set("isDrawerExpanded", true);

View File

@ -0,0 +1,70 @@
import Service, { inject as service } from "@ember/service";
import Promise from "rsvp";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import { tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { popupAjaxError } from "discourse/lib/ajax-error";
/*
The ChatThreadsManager service is responsible for managing the loaded chat threads
for the current chat channel.
It provides helpers to facilitate using and managing loaded threads instead of constantly
fetching them from the server.
*/
export default class ChatThreadsManager extends Service {
@service chatSubscriptionsManager;
@service chatApi;
@service currentUser;
@tracked _cached = new TrackedObject();
async find(channelId, threadId, options = { fetchIfNotFound: true }) {
const existingThread = this.#findStale(threadId);
if (existingThread) {
return Promise.resolve(existingThread);
} else if (options.fetchIfNotFound) {
return this.#find(channelId, threadId);
} else {
return Promise.resolve();
}
}
// whenever the active channel changes, do this
resetCache() {
this._cached = new TrackedObject();
}
get threads() {
return Object.values(this._cached);
}
store(threadObject) {
let model = this.#findStale(threadObject.id);
if (!model) {
model = ChatThread.create(threadObject);
this.#cache(model);
}
return model;
}
async #find(channelId, threadId) {
return this.chatApi
.thread(channelId, threadId)
.catch(popupAjaxError)
.then((thread) => {
this.#cache(thread);
return thread;
});
}
#cache(thread) {
this._cached[thread.id] = thread;
}
#findStale(id) {
return this._cached[id];
}
}

View File

@ -35,9 +35,8 @@ export default class Chat extends Service {
@service router;
@service site;
@service chatChannelsManager;
@tracked activeChannel = null;
@tracked activeThread = null;
cook = null;
presenceChannel = null;
sidebarActive = false;

View File

@ -0,0 +1,2 @@
{{! ChatThreadList will go here later }}
<ChatThread />

View File

@ -1 +1,4 @@
<FullPageChat @targetMessageId={{this.targetMessageId}} />
<ChatSidePanel>
{{outlet}}
</ChatSidePanel>

View File

@ -17,7 +17,10 @@
<ChannelsList />
{{/if}}
<div id="main-chat-outlet">
<div
id="main-chat-outlet"
class={{concat-class "main-chat-outlet" this.mainOutletModifierClasses}}
>
{{outlet}}
</div>
</div>

View File

@ -44,6 +44,7 @@
.react-btn,
.reply-btn,
.chat-message-thread-btn,
.bookmark-btn {
margin-right: -1px;
padding: 0.5em 0;

View File

@ -0,0 +1,23 @@
#main-chat-outlet.chat-view {
min-height: 0;
display: grid;
grid-template-rows: 1fr;
grid-template-areas: "main threads";
grid-template-columns: 1fr;
&.has-side-panel-expanded {
grid-template-columns: 3fr 2fr;
}
}
.chat-side-panel {
grid-area: threads;
min-height: 100%;
box-sizing: border-box;
border-left: 1px solid var(--primary-medium);
&__list {
flex-grow: 1;
padding: 0 1.5em 1em;
}
}

View File

@ -0,0 +1,55 @@
.chat-thread {
display: flex;
flex-direction: column;
padding-block: 1rem;
height: 100%;
box-sizing: border-box;
&__header {
}
&__close {
color: var(--primary-medium);
&:visited {
color: var(--primary-medium);
}
}
&__info {
padding-inline: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--primary-low);
}
&__om {
margin-top: 0;
}
&__omu {
display: flex;
flex-direction: row;
align-items: center;
.chat-message-avatar {
width: var(--message-left-width);
}
}
&__started-by {
margin-right: 0.5rem;
}
&__title {
display: flex;
align-items: center;
justify-content: space-between;
}
&__messages {
flex-grow: 1;
overflow: hidden;
overflow-y: scroll;
padding-inline: 1.5rem;
}
}

View File

@ -588,7 +588,7 @@ html.has-full-page-chat {
padding-bottom: env(safe-area-inset-bottom);
}
#main-chat-outlet {
.main-chat-outlet {
min-height: 0;
}
}

View File

@ -1,6 +1,7 @@
.chat-message-actions {
.react-btn,
.reply-btn,
.chat-message-thread-btn,
.bookmark-btn {
border: 1px solid transparent;
border-bottom-color: var(--primary-low);

View File

@ -75,6 +75,7 @@
.chat-message-reaction,
.reply-btn,
.chat-message-thread-btn,
.react-btn,
.bookmark-btn {
flex-grow: 1;

View File

@ -6,13 +6,24 @@
padding-top: 0.75em;
}
body.has-full-page-chat {
html.has-full-page-chat {
.footer-nav {
display: none !important;
}
#main-outlet {
body #main-outlet {
padding: 0;
.main-chat-outlet {
&.has-side-panel-expanded {
grid-template-columns: 1fr;
grid-template-areas: "threads";
.chat-live-pane {
display: none;
}
}
}
}
}

View File

@ -442,6 +442,11 @@ en:
search_placeholder: "Search by emoji name and alias..."
no_results: "No results"
threads:
op_said: "OP said:"
started_by: "Started by"
open: "Open Thread"
draft_channel_screen:
header: "New Message"
cancel: "Cancel"

View File

@ -116,3 +116,4 @@ chat:
enable_experimental_chat_threaded_discussions:
default: false
hidden: true
client: true

View File

@ -200,5 +200,7 @@ class Chat::ChatMessageCreator
FROM thread_updater
WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id
SQL
@chat_message.thread_id = thread.id
end
end

View File

@ -69,6 +69,8 @@ register_asset "stylesheets/colors.scss", :color_definitions
register_asset "stylesheets/common/reviewable-chat-message.scss"
register_asset "stylesheets/common/chat-mention-warnings.scss"
register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss"
register_asset "stylesheets/common/chat-thread.scss"
register_asset "stylesheets/common/chat-side-panel.scss"
register_svg_icon "comments"
register_svg_icon "comment-slash"
@ -155,6 +157,8 @@ after_initialize do
load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/chat_thread_original_message_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/chat_thread_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__)
load File.expand_path(
"../app/serializers/user_with_custom_fields_and_status_serializer.rb",
@ -237,6 +241,7 @@ after_initialize do
)
load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__)
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
@ -605,6 +610,8 @@ after_initialize do
# Hints for JIT warnings.
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
get "/channels/:channel_id/threads/:thread_id" => "chat_channel_threads#show"
end
# direct_messages_controller routes
@ -657,6 +664,8 @@ after_initialize do
# /channel -> /c redirects
get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}")
get "#{base_c_route}/t/:thread_id" => "chat#respond"
base_channel_route = "/channel/:channel_id/:channel_title"
redirect_base = "/chat/c/%{channel_title}/%{channel_id}"

View File

@ -294,7 +294,7 @@ describe ChatMessage do
"wow check out these birbs https://twitter.com/EffinBirds/status/1518743508378697729",
)
expect(message.excerpt).to eq(
"wow check out these birbs <a href=\"https://twitter.com/EffinBirds/status/1518743508378697729\" class=\"inline-onebox-loading\" rel=\"noopener nofollow ugc\">https://twitter.com/Effi&hellip;</a>",
"wow check out these birbs <a href=\"https://twitter.com/EffinBirds/status/1518743508378697729\" class=\"inline-onebox-loading\" rel=\"noopener nofollow ugc\">https://twitter.com/Effi...</a>",
)
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
require "faker"
module ChatSystemHelpers
def chat_system_bootstrap(user = Fabricate(:admin), channels_for_membership = [])
# ensures we have one valid registered admin/user
@ -20,6 +22,31 @@ module ChatSystemHelpers
# this is reset after each test
Bookmark.register_bookmarkable(ChatMessageBookmarkable)
end
def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4)
last_user = nil
last_message = nil
messages_count.times do |i|
in_reply_to = i.zero? ? nil : last_message.id
thread_id = i.zero? ? nil : last_message.thread_id
last_user = last_user.present? ? (users - [last_user]).sample : users.sample
creator =
Chat::ChatMessageCreator.new(
chat_channel: channel,
in_reply_to_id: in_reply_to,
thread_id: thread_id,
user: last_user,
content: Faker::Lorem.paragraph,
)
creator.create
raise creator.error if creator.error
last_message = creator.chat_message
end
last_message.thread
end
end
RSpec.configure do |config|

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Chat::Api::ChatChannelThreadsController do
fab!(:current_user) { Fabricate(:user) }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.enable_experimental_chat_threaded_discussions = true
Group.refresh_automatic_groups!
sign_in(current_user)
end
describe "show" do
context "when thread does not exist" do
fab!(:thread) { Fabricate(:chat_thread, original_message: Fabricate(:chat_message)) }
it "returns 404" do
thread.destroy!
get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}"
expect(response.status).to eq(404)
end
end
context "when thread exists" do
fab!(:thread) { Fabricate(:chat_thread, original_message: Fabricate(:chat_message)) }
it "works" do
get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}"
expect(response.status).to eq(200)
expect(response.parsed_body["thread"]["id"]).to eq(thread.id)
end
context "when the channel_id does not match the thread id" do
fab!(:other_channel) { Fabricate(:chat_channel) }
it "returns 404" do
get "/chat/api/channels/#{other_channel.id}/threads/#{thread.id}"
expect(response.status).to eq(404)
end
end
context "when enable_experimental_chat_threaded_discussions is disabled" do
before { SiteSetting.enable_experimental_chat_threaded_discussions = false }
it "returns 404" do
get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}"
expect(response.status).to eq(404)
end
end
context "when user cannot access the channel" do
before do
thread.channel.update!(chatable: Fabricate(:private_category, group: Fabricate(:group)))
end
it "returns 403" do
get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}"
expect(response.status).to eq(403)
end
end
context "when user cannot chat" do
before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_4] }
it "returns 403" do
get "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}"
expect(response.status).to eq(403)
end
end
end
end
end

View File

@ -3,6 +3,8 @@
module PageObjects
module Pages
class ChatChannel < PageObjects::Pages::Base
include SystemHelpers
def type_in_composer(input)
find(".chat-composer-input").send_keys(input)
end
@ -32,6 +34,19 @@ module PageObjects
click_more_buttons(message)
end
def expand_message_actions_mobile(message, delay: 2)
message_by_id(message.id).click(delay: delay)
end
def click_message_action_mobile(message, message_action)
i = 0.5
try_until_success(timeout: 20) do
expand_message_actions_mobile(message, delay: i)
first(".chat-message-action-item[data-id=\"#{message_action}\"]")
end
find(".chat-message-action-item[data-id=\"#{message_action}\"] button").click
end
def hover_message(message)
message_by_id(message.id).hover
end
@ -51,6 +66,11 @@ module PageObjects
find("[data-value='flag']").click
end
def open_message_thread(message)
hover_message(message)
find(".chat-message-thread-btn").click
end
def select_message(message)
hover_message(message)
click_more_buttons(message)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module PageObjects
module Pages
class ChatSidePanel < PageObjects::Pages::Base
def has_open_thread?(thread)
has_css?(".chat-side-panel .chat-thread[data-id='#{thread.id}']")
end
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module PageObjects
module Pages
class ChatThread < PageObjects::Pages::Base
def header
find(".chat-thread__header")
end
def omu
header.find(".chat-thread__omu")
end
def has_header_content?(content)
header.has_content?(content)
end
end
end
end

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
describe "Single thread in side panel", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:side_panel) { PageObjects::Pages::ChatSidePanel.new }
let(:open_thread) { PageObjects::Pages::ChatThread.new }
before do
chat_system_bootstrap(current_user, [channel])
sign_in(current_user)
end
context "when enable_experimental_chat_threaded_discussions is disabled" do
fab!(:channel) { Fabricate(:chat_channel) }
before { SiteSetting.enable_experimental_chat_threaded_discussions = false }
it "does not open the side panel for a single thread" do
thread =
chat_thread_chain_bootstrap(channel: channel, users: [current_user, Fabricate(:user)])
chat_page.visit_channel(channel)
channel_page.hover_message(thread.original_message)
expect(page).not_to have_css(".chat-message-thread-btn")
end
end
context "when threading_enabled is false for the channel" do
fab!(:channel) { Fabricate(:chat_channel) }
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
channel.update!(threading_enabled: false)
end
it "does not open the side panel for a single thread" do
thread =
chat_thread_chain_bootstrap(channel: channel, users: [current_user, Fabricate(:user)])
chat_page.visit_channel(channel)
channel_page.hover_message(thread.original_message)
expect(page).not_to have_css(".chat-message-thread-btn")
end
end
context "when enable_experimental_chat_threaded_discussions is true and threading is enabled for the channel" do
fab!(:user_2) { Fabricate(:user) }
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:thread) { chat_thread_chain_bootstrap(channel: channel, users: [current_user, user_2]) }
before { SiteSetting.enable_experimental_chat_threaded_discussions = true }
it "opens the side panel for a single thread from the message actions menu" do
chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message)
expect(side_panel).to have_open_thread(thread)
end
it "shows the excerpt of the thread original message" do
chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message)
expect(open_thread).to have_header_content(thread.excerpt)
end
it "shows the avatar and username of the original message user" do
chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message)
expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar")
expect(open_thread.omu).to have_content(thread.original_message_user.username)
end
context "when using mobile" do
it "opens the side panel for a single thread from the mobile message actions menu",
mobile: true do
chat_page.visit_channel(channel)
channel_page.click_message_action_mobile(thread.chat_messages.last, "openThread")
expect(side_panel).to have_open_thread(thread)
end
end
end
end

View File

@ -25,15 +25,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
end
end
def select_message_mobile(message)
i = 0.5
try_until_success(timeout: 20) do
chat_channel_page.message_by_id(message.id).click(delay: i)
first(".chat-message-action-item[data-id=\"selectMessage\"]")
end
find(".chat-message-action-item[data-id=\"selectMessage\"] button").click
end
def cdp_allow_clipboard_access!
cdp_params = {
origin: page.server_url,
@ -230,7 +221,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
expect(chat_channel_page).to have_no_loading_skeleton
select_message_mobile(message_1)
chat_channel_page.click_message_action_mobile(message_1, "selectMessage")
click_selection_button("quote")
expect(topic_page).to have_expanded_composer

View File

@ -283,13 +283,13 @@ RSpec.configure do |config|
end
Capybara.register_driver :selenium_chrome do |app|
Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: chrome_browser_options)
Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_browser_options)
end
Capybara.register_driver :selenium_chrome_headless do |app|
chrome_browser_options.add_argument("--headless")
Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: chrome_browser_options)
Capybara::Selenium::Driver.new(app, browser: :chrome, options: chrome_browser_options)
end
mobile_chrome_browser_options =
@ -304,20 +304,12 @@ RSpec.configure do |config|
end
Capybara.register_driver :selenium_mobile_chrome do |app|
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
capabilities: mobile_chrome_browser_options,
)
Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options)
end
Capybara.register_driver :selenium_mobile_chrome_headless do |app|
mobile_chrome_browser_options.add_argument("--headless")
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
capabilities: mobile_chrome_browser_options,
)
Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options)
end
if ENV["ELEVATED_UPLOADS_ID"]