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:
parent
cf5fa23cd3
commit
07ab20131a
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"}}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{{#if this.chatStateManager.isSidePanelExpanded}}
|
||||
<div class="chat-side-panel">
|
||||
{{yield}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatSidePanel extends Component {
|
||||
@service chatStateManager;
|
||||
}
|
|
@ -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>
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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(" ");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
{{! ChatThreadList will go here later }}
|
||||
<ChatThread />
|
|
@ -1 +1,4 @@
|
|||
<FullPageChat @targetMessageId={{this.targetMessageId}} />
|
||||
<ChatSidePanel>
|
||||
{{outlet}}
|
||||
</ChatSidePanel>
|
|
@ -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>
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
|
||||
.react-btn,
|
||||
.reply-btn,
|
||||
.chat-message-thread-btn,
|
||||
.bookmark-btn {
|
||||
margin-right: -1px;
|
||||
padding: 0.5em 0;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
|
||||
.chat-message-reaction,
|
||||
.reply-btn,
|
||||
.chat-message-thread-btn,
|
||||
.react-btn,
|
||||
.bookmark-btn {
|
||||
flex-grow: 1;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -116,3 +116,4 @@ chat:
|
|||
enable_experimental_chat_threaded_discussions:
|
||||
default: false
|
||||
hidden: true
|
||||
client: true
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
||||
|
|
|
@ -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…</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
|
||||
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in New Issue