Compare commits
34 Commits
main
...
chat-threa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47ee8fd40 | ||
|
|
ff90a56f5b | ||
|
|
dd9059917f | ||
|
|
176070cd78 | ||
|
|
1a9cb0914c | ||
|
|
074e7ad4ed | ||
|
|
bf2a9063f6 | ||
|
|
54a4251b8e | ||
|
|
eaa49e3764 | ||
|
|
00ddfd761a | ||
|
|
3c568fbf1d | ||
|
|
be8968b05b | ||
|
|
a6af08549b | ||
|
|
218dbc249a | ||
|
|
f6081a9277 | ||
|
|
c19394c184 | ||
|
|
b21664aee1 | ||
|
|
9810c9aba5 | ||
|
|
f341fa9b01 | ||
|
|
77fb4fb2f2 | ||
|
|
81cd3d32f9 | ||
|
|
b33883db2c | ||
|
|
d93cdec8bb | ||
|
|
4a633597f4 | ||
|
|
c9f6cfce62 | ||
|
|
769648652e | ||
|
|
827980903f | ||
|
|
fc65cdacc3 | ||
|
|
e7b810e0f9 | ||
|
|
ca7b55d368 | ||
|
|
588e812d33 | ||
|
|
5699facfca | ||
|
|
b598635753 | ||
|
|
9108ef0383 |
@ -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)
|
UploadReference.insert_all!(ref_record_attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def excerpt
|
def excerpt(max_length: 50)
|
||||||
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
|
# 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)
|
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?
|
return uploads.first.original_filename if cooked.blank? && uploads.present?
|
||||||
|
|
||||||
# this may return blank for some complex things like quotes, that is acceptable
|
# 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
|
end
|
||||||
|
|
||||||
def cooked_for_excerpt
|
def cooked_for_excerpt
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ChatThread < ActiveRecord::Base
|
class ChatThread < ActiveRecord::Base
|
||||||
|
EXCERPT_LENGTH = 150
|
||||||
|
|
||||||
belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel"
|
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_user, foreign_key: "original_message_user_id", class_name: "User"
|
||||||
belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage"
|
belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage"
|
||||||
@ -19,6 +21,10 @@ class ChatThread < ActiveRecord::Base
|
|||||||
def relative_url
|
def relative_url
|
||||||
"#{channel.relative_url}/t/#{self.id}"
|
"#{channel.relative_url}/t/#{self.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def excerpt
|
||||||
|
original_message.excerpt(max_length: EXCERPT_LENGTH)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|||||||
@ -20,7 +20,12 @@ class ChatChannelSerializer < ApplicationSerializer
|
|||||||
:archive_topic_id,
|
:archive_topic_id,
|
||||||
:memberships_count,
|
:memberships_count,
|
||||||
:current_user_membership,
|
:current_user_membership,
|
||||||
:meta
|
:meta,
|
||||||
|
:threading_enabled
|
||||||
|
|
||||||
|
def threading_enabled
|
||||||
|
SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(object, opts)
|
def initialize(object, opts)
|
||||||
super(object, opts)
|
super(object, opts)
|
||||||
|
|||||||
@ -13,7 +13,8 @@ class ChatMessageSerializer < ApplicationSerializer
|
|||||||
:edited,
|
:edited,
|
||||||
:reactions,
|
:reactions,
|
||||||
:bookmark,
|
:bookmark,
|
||||||
:available_flags
|
:available_flags,
|
||||||
|
:thread_id
|
||||||
|
|
||||||
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
|
has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects
|
||||||
has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, 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
|
||||||
17
plugins/chat/app/serializers/chat_thread_serializer.rb
Normal file
17
plugins/chat/app/serializers/chat_thread_serializer.rb
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# 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, :messages
|
||||||
|
|
||||||
|
def messages
|
||||||
|
ActiveModel::ArraySerializer.new(
|
||||||
|
object.chat_messages.order(:created_at).last(50),
|
||||||
|
each_serializer: ChatMessageSerializer,
|
||||||
|
chat_channel: object.channel,
|
||||||
|
scope: scope,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -24,6 +24,7 @@ module ChatPublisher
|
|||||||
message_id: chat_message.id,
|
message_id: chat_message.id,
|
||||||
user_id: chat_message.user.id,
|
user_id: chat_message.user.id,
|
||||||
username: chat_message.user.username,
|
username: chat_message.user.username,
|
||||||
|
thread_id: chat_message.thread_id,
|
||||||
},
|
},
|
||||||
permissions,
|
permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export default function () {
|
|||||||
|
|
||||||
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
|
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
|
||||||
this.route("near-message", { path: "/:messageId" });
|
this.route("near-message", { path: "/:messageId" });
|
||||||
|
this.route("thread", { path: "/t/:threadId" });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route(
|
this.route(
|
||||||
|
|||||||
@ -776,8 +776,15 @@ export default Component.extend({
|
|||||||
id: data.chat_message.id,
|
id: data.chat_message.id,
|
||||||
staged_id: null,
|
staged_id: null,
|
||||||
excerpt: data.chat_message.excerpt,
|
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.
|
// some markdown is cooked differently on the server-side, e.g.
|
||||||
// quotes, avatar images etc.
|
// quotes, avatar images etc.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -37,6 +37,15 @@
|
|||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/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}}
|
{{#if this.secondaryButtons.length}}
|
||||||
<DropdownSelectBox
|
<DropdownSelectBox
|
||||||
@class="more-buttons"
|
@class="more-buttons"
|
||||||
|
|||||||
@ -84,7 +84,7 @@
|
|||||||
{{#if this.message.in_reply_to}}
|
{{#if this.message.in_reply_to}}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onclick={{action "viewReply"}}
|
onclick={{action "viewReplyOrThread"}}
|
||||||
class="chat-reply is-direct-reply"
|
class="chat-reply is-direct-reply"
|
||||||
>
|
>
|
||||||
{{d-icon "share" title="chat.in_reply_to"}}
|
{{d-icon "share" title="chat.in_reply_to"}}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export default Component.extend({
|
|||||||
tagName: "",
|
tagName: "",
|
||||||
chat: service(),
|
chat: service(),
|
||||||
dialog: service(),
|
dialog: service(),
|
||||||
|
router: service(),
|
||||||
chatMessageActionsMobileAnchor: null,
|
chatMessageActionsMobileAnchor: null,
|
||||||
chatMessageActionsDesktopAnchor: null,
|
chatMessageActionsDesktopAnchor: null,
|
||||||
chatMessageEmojiPickerAnchor: 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;
|
return buttons;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -252,6 +261,7 @@ export default Component.extend({
|
|||||||
restore: this.restore,
|
restore: this.restore,
|
||||||
rebakeMessage: this.rebakeMessage,
|
rebakeMessage: this.rebakeMessage,
|
||||||
toggleBookmark: this.toggleBookmark,
|
toggleBookmark: this.toggleBookmark,
|
||||||
|
openThread: this.openThread,
|
||||||
startReactionForMessageActions: this.startReactionForMessageActions,
|
startReactionForMessageActions: this.startReactionForMessageActions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -261,9 +271,15 @@ export default Component.extend({
|
|||||||
canReact: this.canReact,
|
canReact: this.canReact,
|
||||||
canReply: this.canReply,
|
canReply: this.canReply,
|
||||||
canBookmark: this.showBookmarkButton,
|
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")
|
@discourseComputed("message", "details.can_moderate")
|
||||||
show(message, canModerate) {
|
show(message, canModerate) {
|
||||||
return (
|
return (
|
||||||
@ -678,8 +694,12 @@ export default Component.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
@action
|
@action
|
||||||
viewReply() {
|
viewReplyOrThread() {
|
||||||
this.replyMessageClicked(this.message.in_reply_to);
|
if (this.hasThread) {
|
||||||
|
this.router.transitionTo("chat.channel.thread", this.message.thread_id);
|
||||||
|
} else {
|
||||||
|
this.replyMessageClicked(this.message.in_reply_to);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@ -719,6 +739,11 @@ export default Component.extend({
|
|||||||
).catch(popupAjaxError);
|
).catch(popupAjaxError);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
openThread() {
|
||||||
|
this.router.transitionTo("chat.channel.thread", this.message.thread_id);
|
||||||
|
},
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleBookmark() {
|
toggleBookmark() {
|
||||||
return openBookmarkModal(
|
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,44 @@
|
|||||||
|
<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">
|
||||||
|
{{#each this.thread.messages as |message|}}
|
||||||
|
<ChatMessage
|
||||||
|
@message={{message}}
|
||||||
|
@canInteractWithChat="true"
|
||||||
|
@chatChannel={{this.chat.activeChannel}}
|
||||||
|
@fullPage={{this.chatStateManager.isFullPageActive}}
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</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 {
|
export default class ChatController extends Controller {
|
||||||
@service chat;
|
@service chat;
|
||||||
|
@service chatStateManager;
|
||||||
|
@service router;
|
||||||
|
|
||||||
get shouldUseChatSidebar() {
|
get shouldUseChatSidebar() {
|
||||||
if (this.site.mobileView) {
|
if (this.site.mobileView) {
|
||||||
@ -19,4 +21,21 @@ export default class ChatController extends Controller {
|
|||||||
get shouldUseCoreSidebar() {
|
get shouldUseCoreSidebar() {
|
||||||
return this.siteSettings.navigation_menu === "sidebar";
|
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,80 @@
|
|||||||
|
import RestModel from "discourse/models/rest";
|
||||||
|
import I18n from "I18n";
|
||||||
|
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 function threadStatusName(status) {
|
||||||
|
switch (status) {
|
||||||
|
case THREAD_STATUSES.open:
|
||||||
|
return I18n.t("chat.thread_status.open");
|
||||||
|
case THREAD_STATUSES.readOnly:
|
||||||
|
return I18n.t("chat.thread_status.read_only");
|
||||||
|
case THREAD_STATUSES.closed:
|
||||||
|
return I18n.t("chat.thread_status.closed");
|
||||||
|
case THREAD_STATUSES.archived:
|
||||||
|
return I18n.t("chat.thread_status.archived");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const READONLY_STATUSES = [
|
||||||
|
THREAD_STATUSES.closed,
|
||||||
|
THREAD_STATUSES.readOnly,
|
||||||
|
THREAD_STATUSES.archived,
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAFF_READONLY_STATUSES = [
|
||||||
|
THREAD_STATUSES.readOnly,
|
||||||
|
THREAD_STATUSES.archived,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default class ChatThread extends RestModel {
|
||||||
|
@tracked title;
|
||||||
|
@tracked status;
|
||||||
|
|
||||||
|
get escapedTitle() {
|
||||||
|
return escapeExpression(this.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen() {
|
||||||
|
return this.status === THREAD_STATUSES.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReadOnly() {
|
||||||
|
return this.status === THREAD_STATUSES.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isClosed() {
|
||||||
|
return this.status === THREAD_STATUSES.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isArchived() {
|
||||||
|
return this.status === THREAD_STATUSES.archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
canModifyMessages(user) {
|
||||||
|
if (user.staff) {
|
||||||
|
return !STAFF_READONLY_STATUSES.includes(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !READONLY_STATUSES.includes(this.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,25 @@
|
|||||||
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||||
|
|
||||||
|
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();
|
||||||
|
this.chat.activeThread.messages = (
|
||||||
|
this.chat.activeThread.messages || []
|
||||||
|
).map((message) => ChatMessage.create(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,26 @@
|
|||||||
import DiscourseRoute from "discourse/routes/discourse";
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
import withChatChannel from "./chat-channel-decorator";
|
import withChatChannel from "./chat-channel-decorator";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
|
||||||
@withChatChannel
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import Collection from "../lib/collection";
|
|||||||
*/
|
*/
|
||||||
export default class ChatApi extends Service {
|
export default class ChatApi extends Service {
|
||||||
@service chatChannelsManager;
|
@service chatChannelsManager;
|
||||||
|
@service chatThreadsManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a channel by its ID.
|
* Get a channel by its ID.
|
||||||
@ -29,6 +30,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.
|
* List all accessible category channels of the current user.
|
||||||
* @returns {module:Collection}
|
* @returns {module:Collection}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export default class ChatStateManager extends Service {
|
|||||||
@service router;
|
@service router;
|
||||||
isDrawerExpanded = false;
|
isDrawerExpanded = false;
|
||||||
isDrawerActive = false;
|
isDrawerActive = false;
|
||||||
|
isSidePanelExpanded = false;
|
||||||
@tracked _chatURL = null;
|
@tracked _chatURL = null;
|
||||||
@tracked _appURL = null;
|
@tracked _appURL = null;
|
||||||
|
|
||||||
@ -33,6 +34,14 @@ export default class ChatStateManager extends Service {
|
|||||||
this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT });
|
this._store.setObject({ key: PREFERRED_MODE_KEY, value: DRAWER_CHAT });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSidePanel() {
|
||||||
|
this.set("isSidePanelExpanded", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSidePanel() {
|
||||||
|
this.set("isSidePanelExpanded", false);
|
||||||
|
}
|
||||||
|
|
||||||
didOpenDrawer(URL = null) {
|
didOpenDrawer(URL = null) {
|
||||||
this.set("isDrawerActive", true);
|
this.set("isDrawerActive", true);
|
||||||
this.set("isDrawerExpanded", 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 router;
|
||||||
@service site;
|
@service site;
|
||||||
@service chatChannelsManager;
|
@service chatChannelsManager;
|
||||||
|
|
||||||
@tracked activeChannel = null;
|
@tracked activeChannel = null;
|
||||||
|
@tracked activeThread = null;
|
||||||
cook = null;
|
cook = null;
|
||||||
presenceChannel = null;
|
presenceChannel = null;
|
||||||
sidebarActive = false;
|
sidebarActive = false;
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
{{! ChatThreadList will go here later }}
|
||||||
|
<ChatThread />
|
||||||
@ -1 +1,4 @@
|
|||||||
<FullPageChat @targetMessageId={{this.targetMessageId}} />
|
<FullPageChat @targetMessageId={{this.targetMessageId}} />
|
||||||
|
<ChatSidePanel>
|
||||||
|
{{outlet}}
|
||||||
|
</ChatSidePanel>
|
||||||
@ -17,7 +17,10 @@
|
|||||||
<ChannelsList />
|
<ChannelsList />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div id="main-chat-outlet">
|
<div
|
||||||
|
id="main-chat-outlet"
|
||||||
|
class={{concat-class "main-chat-outlet" this.mainOutletModifierClasses}}
|
||||||
|
>
|
||||||
{{outlet}}
|
{{outlet}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
|
|
||||||
.react-btn,
|
.react-btn,
|
||||||
.reply-btn,
|
.reply-btn,
|
||||||
|
.chat-message-thread-btn,
|
||||||
.bookmark-btn {
|
.bookmark-btn {
|
||||||
margin-right: -1px;
|
margin-right: -1px;
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
|
|||||||
23
plugins/chat/assets/stylesheets/common/chat-side-panel.scss
Normal file
23
plugins/chat/assets/stylesheets/common/chat-side-panel.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
plugins/chat/assets/stylesheets/common/chat-thread.scss
Normal file
55
plugins/chat/assets/stylesheets/common/chat-thread.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -588,7 +588,7 @@ html.has-full-page-chat {
|
|||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-chat-outlet {
|
.main-chat-outlet {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
.chat-message-actions {
|
.chat-message-actions {
|
||||||
.react-btn,
|
.react-btn,
|
||||||
.reply-btn,
|
.reply-btn,
|
||||||
|
.chat-message-thread-btn,
|
||||||
.bookmark-btn {
|
.bookmark-btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-bottom-color: var(--primary-low);
|
border-bottom-color: var(--primary-low);
|
||||||
|
|||||||
@ -75,6 +75,7 @@
|
|||||||
|
|
||||||
.chat-message-reaction,
|
.chat-message-reaction,
|
||||||
.reply-btn,
|
.reply-btn,
|
||||||
|
.chat-message-thread-btn,
|
||||||
.react-btn,
|
.react-btn,
|
||||||
.bookmark-btn {
|
.bookmark-btn {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|||||||
@ -6,13 +6,24 @@
|
|||||||
padding-top: 0.75em;
|
padding-top: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.has-full-page-chat {
|
html.has-full-page-chat {
|
||||||
.footer-nav {
|
.footer-nav {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-outlet {
|
body #main-outlet {
|
||||||
padding: 0;
|
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..."
|
search_placeholder: "Search by emoji name and alias..."
|
||||||
no_results: "No results"
|
no_results: "No results"
|
||||||
|
|
||||||
|
threads:
|
||||||
|
op_said: "OP said:"
|
||||||
|
started_by: "Started by"
|
||||||
|
open: "Open Thread"
|
||||||
|
|
||||||
draft_channel_screen:
|
draft_channel_screen:
|
||||||
header: "New Message"
|
header: "New Message"
|
||||||
cancel: "Cancel"
|
cancel: "Cancel"
|
||||||
|
|||||||
@ -116,3 +116,4 @@ chat:
|
|||||||
enable_experimental_chat_threaded_discussions:
|
enable_experimental_chat_threaded_discussions:
|
||||||
default: false
|
default: false
|
||||||
hidden: true
|
hidden: true
|
||||||
|
client: true
|
||||||
|
|||||||
@ -200,5 +200,7 @@ class Chat::ChatMessageCreator
|
|||||||
FROM thread_updater
|
FROM thread_updater
|
||||||
WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id
|
WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
|
@chat_message.thread_id = thread.id
|
||||||
end
|
end
|
||||||
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/reviewable-chat-message.scss"
|
||||||
register_asset "stylesheets/common/chat-mention-warnings.scss"
|
register_asset "stylesheets/common/chat-mention-warnings.scss"
|
||||||
register_asset "stylesheets/common/chat-channel-settings-saved-indicator.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 "comments"
|
||||||
register_svg_icon "comment-slash"
|
register_svg_icon "comment-slash"
|
||||||
@ -153,6 +155,8 @@ after_initialize do
|
|||||||
load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__)
|
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_index_serializer.rb", __FILE__)
|
||||||
load File.expand_path("../app/serializers/chat_channel_search_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/chat_view_serializer.rb", __FILE__)
|
||||||
load File.expand_path(
|
load File.expand_path(
|
||||||
"../app/serializers/user_with_custom_fields_and_status_serializer.rb",
|
"../app/serializers/user_with_custom_fields_and_status_serializer.rb",
|
||||||
@ -228,6 +232,7 @@ after_initialize do
|
|||||||
)
|
)
|
||||||
load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__)
|
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/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/controllers/api/chat_chatables_controller.rb", __FILE__)
|
||||||
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
|
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
|
||||||
|
|
||||||
@ -596,6 +601,8 @@ after_initialize do
|
|||||||
|
|
||||||
# Hints for JIT warnings.
|
# Hints for JIT warnings.
|
||||||
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
|
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
|
||||||
|
|
||||||
|
get "/channels/:channel_id/threads/:thread_id" => "chat_channel_threads#show"
|
||||||
end
|
end
|
||||||
|
|
||||||
# direct_messages_controller routes
|
# direct_messages_controller routes
|
||||||
@ -648,6 +655,8 @@ after_initialize do
|
|||||||
# /channel -> /c redirects
|
# /channel -> /c redirects
|
||||||
get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}")
|
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"
|
base_channel_route = "/channel/:channel_id/:channel_title"
|
||||||
redirect_base = "/chat/c/%{channel_title}/%{channel_id}"
|
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",
|
"wow check out these birbs https://twitter.com/EffinBirds/status/1518743508378697729",
|
||||||
)
|
)
|
||||||
expect(message.excerpt).to eq(
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faker"
|
||||||
|
|
||||||
module ChatSystemHelpers
|
module ChatSystemHelpers
|
||||||
def chat_system_bootstrap(user = Fabricate(:admin), channels_for_membership = [])
|
def chat_system_bootstrap(user = Fabricate(:admin), channels_for_membership = [])
|
||||||
# ensures we have one valid registered admin/user
|
# ensures we have one valid registered admin/user
|
||||||
@ -20,6 +22,31 @@ module ChatSystemHelpers
|
|||||||
# this is reset after each test
|
# this is reset after each test
|
||||||
Bookmark.register_bookmarkable(ChatMessageBookmarkable)
|
Bookmark.register_bookmarkable(ChatMessageBookmarkable)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
RSpec.configure { |config| config.include ChatSystemHelpers, type: :system }
|
RSpec.configure { |config| config.include ChatSystemHelpers, type: :system }
|
||||||
|
|||||||
@ -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 PageObjects
|
||||||
module Pages
|
module Pages
|
||||||
class ChatChannel < PageObjects::Pages::Base
|
class ChatChannel < PageObjects::Pages::Base
|
||||||
|
include SystemHelpers
|
||||||
|
|
||||||
def type_in_composer(input)
|
def type_in_composer(input)
|
||||||
find(".chat-composer-input").send_keys(input)
|
find(".chat-composer-input").send_keys(input)
|
||||||
end
|
end
|
||||||
@ -32,6 +34,19 @@ module PageObjects
|
|||||||
click_more_buttons(message)
|
click_more_buttons(message)
|
||||||
end
|
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)
|
def hover_message(message)
|
||||||
message_by_id(message.id).hover
|
message_by_id(message.id).hover
|
||||||
end
|
end
|
||||||
@ -51,6 +66,11 @@ module PageObjects
|
|||||||
find("[data-value='flag']").click
|
find("[data-value='flag']").click
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def open_message_thread(message)
|
||||||
|
hover_message(message)
|
||||||
|
find(".chat-message-thread-btn").click
|
||||||
|
end
|
||||||
|
|
||||||
def select_message(message)
|
def select_message(message)
|
||||||
hover_message(message)
|
hover_message(message)
|
||||||
click_more_buttons(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
|
||||||
19
plugins/chat/spec/system/page_objects/chat/chat_thread.rb
Normal file
19
plugins/chat/spec/system/page_objects/chat/chat_thread.rb
Normal 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
|
||||||
80
plugins/chat/spec/system/single_thread_spec.rb
Normal file
80
plugins/chat/spec/system/single_thread_spec.rb
Normal 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
|
||||||
@ -25,15 +25,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||||||
end
|
end
|
||||||
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!
|
def cdp_allow_clipboard_access!
|
||||||
cdp_params = {
|
cdp_params = {
|
||||||
origin: page.server_url,
|
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
|
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")
|
click_selection_button("quote")
|
||||||
|
|
||||||
expect(topic_page).to have_expanded_composer
|
expect(topic_page).to have_expanded_composer
|
||||||
|
|||||||
@ -283,13 +283,13 @@ RSpec.configure do |config|
|
|||||||
end
|
end
|
||||||
|
|
||||||
Capybara.register_driver :selenium_chrome do |app|
|
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
|
end
|
||||||
|
|
||||||
Capybara.register_driver :selenium_chrome_headless do |app|
|
Capybara.register_driver :selenium_chrome_headless do |app|
|
||||||
chrome_browser_options.add_argument("--headless")
|
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
|
end
|
||||||
|
|
||||||
mobile_chrome_browser_options =
|
mobile_chrome_browser_options =
|
||||||
@ -304,20 +304,12 @@ RSpec.configure do |config|
|
|||||||
end
|
end
|
||||||
|
|
||||||
Capybara.register_driver :selenium_mobile_chrome do |app|
|
Capybara.register_driver :selenium_mobile_chrome do |app|
|
||||||
Capybara::Selenium::Driver.new(
|
Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options)
|
||||||
app,
|
|
||||||
browser: :chrome,
|
|
||||||
capabilities: mobile_chrome_browser_options,
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Capybara.register_driver :selenium_mobile_chrome_headless do |app|
|
Capybara.register_driver :selenium_mobile_chrome_headless do |app|
|
||||||
mobile_chrome_browser_options.add_argument("--headless")
|
mobile_chrome_browser_options.add_argument("--headless")
|
||||||
Capybara::Selenium::Driver.new(
|
Capybara::Selenium::Driver.new(app, browser: :chrome, options: mobile_chrome_browser_options)
|
||||||
app,
|
|
||||||
browser: :chrome,
|
|
||||||
capabilities: mobile_chrome_browser_options,
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if ENV["ELEVATED_UPLOADS_ID"]
|
if ENV["ELEVATED_UPLOADS_ID"]
|
||||||
|
|||||||
Reference in New Issue
Block a user