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)
|
||||
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
|
||||
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,
|
||||
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,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 {
|
||||
@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,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 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import Collection from "../lib/collection";
|
||||
*/
|
||||
export default class ChatApi extends Service {
|
||||
@service chatChannelsManager;
|
||||
@service chatThreadsManager;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {module: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}} />
|
||||
<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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#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"
|
||||
@ -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_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",
|
||||
@ -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/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__)
|
||||
|
||||
@ -596,6 +601,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
|
||||
@ -648,6 +655,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 { |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 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
|
||||
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
|
||||
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user