FEATURE: Thread list initial UI (#21412)
This commit adds an initial thread list UI. There are several limitations with this that will be addressed in future PRs: * There is no MessageBus reactivity, so e.g. if someone edits the original message of the thread it will not be reflected in the list. However if the thread title is updated the original message indicator will be updated. * There is no unread functionality for threads in the list, if new messages come into the thread there is no indicator in the UI. * There is no unread indicator on the actual button to open the thread list. * No pagination. In saying that, this is the functionality so far: * We show a list of the 50 threads that the user has most recently participated in (i.e. sent a message) for the channel in descending order. * Each thread we show a rich excerpt, the title, and the user who is the OM creator. * The title is editable by staff and by the OM creator. * Thread indicators show a title. We also replace emojis in the titles. * Thread list works in the drawer/mobile.
This commit is contained in:
parent
7a84fc3d9d
commit
c6b43ce68b
|
@ -1,6 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelThreadsController < Chat::ApiController
|
||||
def index
|
||||
with_service(::Chat::LookupChannelThreads) do
|
||||
on_success do
|
||||
render_serialized(
|
||||
::Chat::ThreadsView.new(
|
||||
user: guardian.user,
|
||||
threads: result.threads,
|
||||
channel: result.channel,
|
||||
),
|
||||
::Chat::ThreadListSerializer,
|
||||
root: false,
|
||||
)
|
||||
end
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_model_not_found(:channel) { raise Discourse::NotFound }
|
||||
on_model_not_found(:threads) { render json: success_json.merge(threads: []) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
with_service(::Chat::LookupThread) do
|
||||
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") }
|
||||
|
@ -9,4 +30,17 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
|||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
with_service(::Chat::UpdateThread) do
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_failed_policy(:can_edit_thread) { raise Discourse::InvalidAccess }
|
||||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
on_failed_step(:update) do
|
||||
render json: failed_json.merge(errors: [result["result.step.update"].error]), status: 422
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -128,6 +128,24 @@ module Chat
|
|||
PrettyText.excerpt(message, max_length, { text_entities: true })
|
||||
end
|
||||
|
||||
# TODO (martin) Replace the above #excerpt method usage with this one. The
|
||||
# issue with the above one is that we cannot actually render nice HTML
|
||||
# fore replies/excerpts in the UI because text_entitites: true will
|
||||
# allow through even denied HTML because of 07ab20131a15ab907c1974fee405d9bdce0c0723.
|
||||
#
|
||||
# For now only the thread index uses this new version since it is not interactive,
|
||||
# we can go back to the interactive reply/edit cases in another PR.
|
||||
def rich_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)
|
||||
|
||||
# upload-only messages are better represented as the filename
|
||||
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, max_length)
|
||||
end
|
||||
|
||||
def cooked_for_excerpt
|
||||
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
|
||||
end
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
module Chat
|
||||
class Thread < ActiveRecord::Base
|
||||
EXCERPT_LENGTH = 150
|
||||
MAX_TITLE_LENGTH = 100
|
||||
|
||||
include Chat::ThreadCache
|
||||
|
||||
|
@ -24,6 +25,8 @@ module Chat
|
|||
|
||||
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
|
||||
|
||||
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
|
||||
|
||||
def replies
|
||||
self.chat_messages.where.not(id: self.original_message_id)
|
||||
end
|
||||
|
@ -37,7 +40,7 @@ module Chat
|
|||
end
|
||||
|
||||
def excerpt
|
||||
original_message.excerpt(max_length: EXCERPT_LENGTH)
|
||||
original_message.rich_excerpt(max_length: EXCERPT_LENGTH)
|
||||
end
|
||||
|
||||
def self.grouped_messages(thread_ids: nil, message_ids: nil, include_original_message: true)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadsView
|
||||
attr_reader :user, :channel, :threads
|
||||
|
||||
def initialize(channel:, threads:, user:)
|
||||
@channel = channel
|
||||
@threads = threads
|
||||
@user = user
|
||||
end
|
||||
end
|
||||
end
|
|
@ -17,6 +17,7 @@ module Chat
|
|||
:available_flags,
|
||||
:thread_id,
|
||||
:thread_reply_count,
|
||||
:thread_title,
|
||||
:chat_channel_id
|
||||
|
||||
has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects
|
||||
|
@ -168,5 +169,9 @@ module Chat
|
|||
def thread_reply_count
|
||||
object.thread&.replies_count_cache || 0
|
||||
end
|
||||
|
||||
def thread_title
|
||||
object.thread&.title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadListSerializer < ApplicationSerializer
|
||||
attributes :meta, :threads
|
||||
|
||||
def threads
|
||||
ActiveModel::ArraySerializer.new(
|
||||
object.threads,
|
||||
each_serializer: Chat::ThreadSerializer,
|
||||
scope: scope,
|
||||
)
|
||||
end
|
||||
|
||||
def meta
|
||||
{ channel_id: object.channel.id }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,13 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadOriginalMessageSerializer < ApplicationSerializer
|
||||
attributes :id, :created_at, :excerpt, :thread_id
|
||||
|
||||
has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects
|
||||
|
||||
class ThreadOriginalMessageSerializer < Chat::MessageSerializer
|
||||
def excerpt
|
||||
WordWatcher.censor(object.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH))
|
||||
WordWatcher.censor(object.rich_excerpt(max_length: Chat::Thread::EXCERPT_LENGTH))
|
||||
end
|
||||
|
||||
def include_reactions?
|
||||
false
|
||||
end
|
||||
|
||||
def include_edited?
|
||||
false
|
||||
end
|
||||
|
||||
def include_in_reply_to?
|
||||
false
|
||||
end
|
||||
|
||||
def include_user_flag_status?
|
||||
false
|
||||
end
|
||||
|
||||
def include_uploads?
|
||||
false
|
||||
end
|
||||
|
||||
def include_bookmark?
|
||||
false
|
||||
end
|
||||
|
||||
def include_chat_webhook_event?
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,17 +5,22 @@ module Chat
|
|||
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects
|
||||
has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects
|
||||
|
||||
attributes :id, :title, :status, :channel_id, :meta
|
||||
attributes :id, :title, :status, :channel_id, :meta, :reply_count
|
||||
|
||||
def initialize(object, opts)
|
||||
super(object, opts)
|
||||
@opts = opts
|
||||
original_message.thread = object
|
||||
end
|
||||
|
||||
def meta
|
||||
{ message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } }
|
||||
end
|
||||
|
||||
def reply_count
|
||||
object.replies_count_cache || 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def thread_message_bus_last_id
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Gets a list of threads for a channel to be shown in an index.
|
||||
# In future pagination and filtering will be added -- for now
|
||||
# we just want to return N threads ordered by the latest
|
||||
# message that the user has sent in a thread.
|
||||
#
|
||||
# @example
|
||||
# Chat::LookupChannelThreads.call(channel_id: 2, guardian: guardian)
|
||||
#
|
||||
class LookupChannelThreads
|
||||
include Service::Base
|
||||
|
||||
# @!method call(channel_id:, guardian:)
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
policy :threaded_discussions_enabled
|
||||
contract
|
||||
model :channel
|
||||
policy :threading_enabled_for_channel
|
||||
policy :can_view_channel
|
||||
model :threads
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :channel_id, :integer
|
||||
validates :channel_id, presence: true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def threaded_discussions_enabled
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions
|
||||
end
|
||||
|
||||
def fetch_channel(contract:, **)
|
||||
Chat::Channel.find_by(id: contract.channel_id)
|
||||
end
|
||||
|
||||
def threading_enabled_for_channel(channel:, **)
|
||||
channel.threading_enabled
|
||||
end
|
||||
|
||||
def can_view_channel(guardian:, channel:, **)
|
||||
guardian.can_preview_chat_channel?(channel)
|
||||
end
|
||||
|
||||
def fetch_threads(guardian:, channel:, **)
|
||||
Chat::Thread
|
||||
.includes(
|
||||
:channel,
|
||||
original_message_user: :user_status,
|
||||
original_message: :chat_webhook_event,
|
||||
)
|
||||
.select("chat_threads.*, MAX(chat_messages.created_at) AS last_posted_at")
|
||||
.joins(
|
||||
"LEFT JOIN chat_messages ON chat_threads.id = chat_messages.thread_id AND chat_messages.chat_channel_id = #{channel.id}",
|
||||
)
|
||||
.joins(
|
||||
"LEFT JOIN chat_messages original_messages ON chat_threads.original_message_id = original_messages.id",
|
||||
)
|
||||
.where("chat_messages.user_id = ? OR chat_messages.user_id IS NULL", guardian.user.id)
|
||||
.where(channel_id: channel.id)
|
||||
.where("original_messages.deleted_at IS NULL AND chat_messages.deleted_at IS NULL")
|
||||
.group("chat_threads.id")
|
||||
.order("last_posted_at DESC NULLS LAST")
|
||||
.limit(50)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -72,6 +72,7 @@ module Chat
|
|||
type: :update_thread_original_message,
|
||||
original_message_id: thread.original_message_id,
|
||||
replies_count: thread.replies_count_cache,
|
||||
title: thread.title,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Updates a thread. The thread_id and channel_id must
|
||||
# match. For now we do not want to allow updating threads if the
|
||||
# enable_experimental_chat_threaded_discussions hidden site setting
|
||||
# is not turned on, and the channel must specifically have threading
|
||||
# enabled.
|
||||
#
|
||||
# Only the thread title can be updated.
|
||||
#
|
||||
# @example
|
||||
# Chat::UpdateThread.call(thread_id: 88, channel_id: 2, guardian: guardian, title: "Restaurant for Saturday")
|
||||
#
|
||||
class UpdateThread
|
||||
include Service::Base
|
||||
|
||||
# @!method call(thread_id:, channel_id:, guardian:, **params_to_edit)
|
||||
# @param [Integer] thread_id
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @option params_to_edit [String,nil] title
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
policy :threaded_discussions_enabled
|
||||
contract
|
||||
model :thread, :fetch_thread
|
||||
policy :can_view_channel
|
||||
policy :can_edit_thread
|
||||
policy :threading_enabled_for_channel
|
||||
step :update
|
||||
step :publish_metadata
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :thread_id, :integer
|
||||
attribute :channel_id, :integer
|
||||
attribute :title, :string
|
||||
|
||||
validates :thread_id, :channel_id, presence: true
|
||||
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def threaded_discussions_enabled
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions
|
||||
end
|
||||
|
||||
def fetch_thread(contract:, **)
|
||||
Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id)
|
||||
end
|
||||
|
||||
def can_view_channel(guardian:, thread:, **)
|
||||
guardian.can_preview_chat_channel?(thread.channel)
|
||||
end
|
||||
|
||||
def can_edit_thread(guardian:, thread:, **)
|
||||
guardian.can_edit_thread?(thread)
|
||||
end
|
||||
|
||||
def threading_enabled_for_channel(thread:, **)
|
||||
thread.channel.threading_enabled
|
||||
end
|
||||
|
||||
def update(thread:, contract:, **)
|
||||
thread.update(title: contract.title)
|
||||
fail!(thread.errors.full_messages.join(", ")) if thread.invalid?
|
||||
end
|
||||
|
||||
def publish_metadata(thread:, **)
|
||||
Chat::Publisher.publish_thread_original_message_metadata!(thread)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,6 +7,7 @@ export default function () {
|
|||
|
||||
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
|
||||
this.route("near-message", { path: "/:messageId" });
|
||||
this.route("threads", { path: "/t" });
|
||||
this.route("thread", { path: "/t/:threadId" });
|
||||
});
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
class="chat-messages-container"
|
||||
{{chat/on-resize this.didResizePane (hash delay=25 immediate=true)}}
|
||||
>
|
||||
|
||||
{{#if this.loadedOnce}}
|
||||
{{#each @channel.messages key="id" as |message|}}
|
||||
<ChatMessage
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
<div class="chat-drawer-header__right-actions">
|
||||
<div class="chat-drawer-header__top-line">
|
||||
{{#if this.chat.activeChannel.threadingEnabled}}
|
||||
<ChatDrawer::Header::ThreadListButton />
|
||||
{{/if}}
|
||||
|
||||
<ChatDrawer::Header::ToggleExpandButton
|
||||
@toggleExpand={{@drawerActions.toggleExpand}}
|
||||
/>
|
||||
|
||||
<ChatDrawer::Header::FullPageButton
|
||||
@openInFullPage={{@drawerActions.openInFullPage}}
|
||||
/>
|
||||
|
||||
<ChatDrawer::Header::CloseButton @close={{@drawerActions.close}} />
|
||||
</div>
|
||||
</div>
|
|
@ -2,5 +2,6 @@ import Component from "@glimmer/component";
|
|||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatDrawerHeaderRightActions extends Component {
|
||||
@service chat;
|
||||
@service chatStateManager;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<LinkTo
|
||||
@route="chat.channel.threads"
|
||||
@models={{this.chat.activeChannel.routeModels}}
|
||||
title={{i18n "chat.threads.list"}}
|
||||
class="open-thread-list-btn btn btn-flat"
|
||||
{{on "click" this.stopPropagation}}
|
||||
>
|
||||
{{d-icon "comments"}}
|
||||
</LinkTo>
|
|
@ -0,0 +1,11 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
export default class ChatDrawerThreadListButton extends Component {
|
||||
@service chat;
|
||||
|
||||
@action
|
||||
stopPropagation(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
|
@ -3,9 +3,9 @@
|
|||
<div class="chat-drawer-header__left-actions">
|
||||
<div class="chat-drawer-header__top-line">
|
||||
<ChatDrawer::Header::BackLink
|
||||
@route="chat.channel"
|
||||
@title={{i18n "chat.return_to_list"}}
|
||||
@routeModels={{this.chat.activeChannel.routeModels}}
|
||||
@route={{this.backLink.route}}
|
||||
@title={{i18n this.backLink.title}}
|
||||
@routeModels={{this.backLink.models}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,23 @@ export default class ChatDrawerThread extends Component {
|
|||
@service chat;
|
||||
@service chatStateManager;
|
||||
@service chatChannelsManager;
|
||||
@service chatDrawerRouter;
|
||||
|
||||
get backLink() {
|
||||
const link = {
|
||||
models: this.chat.activeChannel.routeModels,
|
||||
};
|
||||
|
||||
if (this.chatDrawerRouter.previousRouteName === "chat.channel.threads") {
|
||||
link.title = "chat.return_to_threads_list";
|
||||
link.route = "chat.channel.threads";
|
||||
} else {
|
||||
link.title = "chat.return_to_list";
|
||||
link.route = "chat.channel";
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchChannelAndThread() {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
<ChatDrawer::Header @toggleExpand={{@drawerActions.toggleExpand}}>
|
||||
{{#if (and this.chatStateManager.isDrawerExpanded this.chat.activeChannel)}}
|
||||
<div class="chat-drawer-header__left-actions">
|
||||
<div class="chat-drawer-header__top-line">
|
||||
<ChatDrawer::Header::BackLink
|
||||
@route="chat.channel"
|
||||
@title={{i18n "chat.return_to_list"}}
|
||||
@routeModels={{this.chat.activeChannel.routeModels}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<ChatDrawer::Header::Title @title="chat.threads.list" />
|
||||
|
||||
<ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} />
|
||||
</ChatDrawer::Header>
|
||||
|
||||
{{#if this.chatStateManager.isDrawerExpanded}}
|
||||
<div class="chat-drawer-content" {{did-insert this.fetchChannel}}>
|
||||
{{#if this.chat.activeChannel}}
|
||||
<Chat::Thread::List
|
||||
@channel={{this.chat.activeChannel}}
|
||||
@includeHeader={{false}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,23 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class ChatDrawerThreads extends Component {
|
||||
@service appEvents;
|
||||
@service chat;
|
||||
@service chatStateManager;
|
||||
@service chatChannelsManager;
|
||||
|
||||
@action
|
||||
fetchChannel() {
|
||||
if (!this.args.params?.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.chatChannelsManager
|
||||
.find(this.args.params.channelId)
|
||||
.then((channel) => {
|
||||
this.chat.activeChannel = channel;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -29,14 +29,27 @@
|
|||
<ChatChannelTitle @channel={{@channel}} />
|
||||
</LinkTo>
|
||||
|
||||
{{#if this.site.desktopView}}
|
||||
{{#if (or @channel.threadingEnabled this.site.desktopView)}}
|
||||
<div class="chat-full-page-header__right-actions">
|
||||
<DButton
|
||||
@icon="discourse-compress"
|
||||
@title="chat.close_full_page"
|
||||
class="open-drawer-btn btn-flat no-text"
|
||||
@action={{@onCloseFullScreen}}
|
||||
/>
|
||||
{{#if @channel.threadingEnabled}}
|
||||
<LinkTo
|
||||
@route="chat.channel.threads"
|
||||
@models={{@channel.routeModels}}
|
||||
title={{i18n "chat.threads.list"}}
|
||||
class="open-thread-list-btn btn btn-flat"
|
||||
>
|
||||
{{d-icon "comments"}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.site.desktopView}}
|
||||
<DButton
|
||||
@icon="discourse-compress"
|
||||
@title="chat.close_full_page"
|
||||
class="open-drawer-btn btn-flat no-text"
|
||||
@action={{@onCloseFullScreen}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
<span class="chat-message-thread-indicator__replies-count">
|
||||
{{i18n "chat.thread.replies" count=@message.threadReplyCount}}
|
||||
</span>
|
||||
<span class="chat-message-thread-indicator__view-thread">
|
||||
{{i18n "chat.thread.view_thread"}}
|
||||
<span class="chat-message-thread-indicator__view-thread overflow-ellipsis">
|
||||
{{i18n "chat.thread.view_thread"}}{{#if this.threadTitle}}:
|
||||
{{replace-emoji this.threadTitle}}{{/if}}
|
||||
</span>
|
||||
</LinkTo>
|
|
@ -1,3 +1,8 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
|
||||
export default class ChatMessageThreadIndicator extends Component {}
|
||||
export default class ChatMessageThreadIndicator extends Component {
|
||||
get threadTitle() {
|
||||
return escapeExpression(this.args.message.threadTitle);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
{{did-update this.subscribeToUpdates this.thread.id}}
|
||||
{{did-insert this.loadMessages}}
|
||||
{{did-update this.loadMessages this.thread}}
|
||||
{{did-insert this.setupMessage}}
|
||||
{{will-destroy this.unsubscribeFromUpdates}}
|
||||
>
|
||||
{{#if @includeHeader}}
|
||||
|
|
|
@ -43,14 +43,6 @@ export default class ChatThreadPanel extends Component {
|
|||
this.uploadDropZone = element;
|
||||
}
|
||||
|
||||
@action
|
||||
setupMessage() {
|
||||
this.chatChannelThreadComposer.message = ChatMessage.createDraftMessage(
|
||||
this.channel,
|
||||
{ user: this.currentUser, thread_id: this.thread.id }
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
subscribeToUpdates() {
|
||||
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
|
||||
|
@ -68,6 +60,13 @@ export default class ChatThreadPanel extends Component {
|
|||
|
||||
@action
|
||||
loadMessages() {
|
||||
const message = ChatMessage.createDraftMessage(this.channel, {
|
||||
user: this.currentUser,
|
||||
});
|
||||
message.thread = this.thread;
|
||||
message.inReplyTo = this.thread.originalMessage;
|
||||
this.chatChannelThreadComposer.message = message;
|
||||
|
||||
this.thread.messagesManager.clearMessages();
|
||||
this.fetchMessages();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<div class="chat-thread-list-item" data-thread-id={{@thread.id}}>
|
||||
<div class="chat-thread-list-item__main">
|
||||
<div
|
||||
title={{i18n "chat.thread.view_thread"}}
|
||||
role="button"
|
||||
class="chat-thread-list-item__open-button"
|
||||
{{on "click" (fn this.openThread @thread) passive=true}}
|
||||
>
|
||||
<div class="chat-thread-list-item__header">
|
||||
<div class="chat-thread-list-item__title">
|
||||
{{replace-emoji this.title}}
|
||||
</div>
|
||||
<div class="chat-thread-list-item__buttons">
|
||||
<DButton
|
||||
@action={{action this.openThreadSettings}}
|
||||
@class="btn-flat chat-thread-list-item__settings"
|
||||
@icon="cog"
|
||||
@title="chat.thread.settings"
|
||||
@disabled={{not this.canChangeThreadSettings}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Chat::Thread::OriginalMessage
|
||||
@thread={{@thread}}
|
||||
@message={{@thread.originalMessage}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,37 @@
|
|||
import Component from "@glimmer/component";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import { inject as service } from "@ember/service";
|
||||
import I18n from "I18n";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class ChatThreadListItem extends Component {
|
||||
@service currentUser;
|
||||
@service router;
|
||||
|
||||
get title() {
|
||||
return (
|
||||
this.args.thread.escapedTitle ||
|
||||
`${I18n.t("chat.thread.default_title", {
|
||||
thread_id: this.args.thread.id,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
get canChangeThreadSettings() {
|
||||
return (
|
||||
this.currentUser.staff ||
|
||||
this.currentUser.id === this.args.thread.originalMessage.user.id
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
openThreadSettings() {
|
||||
const controller = showModal("chat-thread-settings-modal");
|
||||
controller.set("thread", this.args.thread);
|
||||
}
|
||||
|
||||
@action
|
||||
openThread(thread) {
|
||||
this.router.transitionTo("chat.channel.thread", ...thread.routeModels);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
<div
|
||||
class="chat-thread-list"
|
||||
{{did-insert this.loadThreads}}
|
||||
{{did-update this.loadThreads @channel}}
|
||||
{{will-destroy this.teardown}}
|
||||
>
|
||||
{{#if @includeHeader}}
|
||||
<div class="chat-thread__header">
|
||||
<span class="chat-thread__label">{{i18n "chat.threads.list"}}</span>
|
||||
<LinkTo
|
||||
class="chat-thread__close btn-flat btn btn-icon no-text"
|
||||
@route="chat.channel"
|
||||
@models={{@channel.routeModels}}
|
||||
title={{i18n "chat.thread.close"}}
|
||||
>
|
||||
{{d-icon "times"}}
|
||||
</LinkTo>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="chat-thread-list__items">
|
||||
{{#if this.loading}}
|
||||
{{loading-spinner size="medium"}}
|
||||
{{else}}
|
||||
{{#each this.threads as |thread|}}
|
||||
<Chat::Thread::ListItem @thread={{thread}} />
|
||||
{{else}}
|
||||
<div class="chat-thread-list__no-threads">
|
||||
{{i18n "chat.threads.none"}}
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,36 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatThreadList extends Component {
|
||||
@service chat;
|
||||
|
||||
@tracked threads;
|
||||
@tracked loading = true;
|
||||
|
||||
@action
|
||||
loadThreads() {
|
||||
if (!this.args.channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.args.channel.threadsManager
|
||||
.index(this.args.channel.id)
|
||||
.then((result) => {
|
||||
if (result.meta.channel_id === this.args.channel.id) {
|
||||
this.threads = result.threads;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
teardown() {
|
||||
this.loading = true;
|
||||
this.threads = null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
<div class="chat-thread-original-message">
|
||||
<div class="chat-thread-original-message__inner-container">
|
||||
<div class="chat-thread-original-message__excerpt">
|
||||
{{replace-emoji (html-safe @message.excerpt)}}
|
||||
</div>
|
||||
<div class="chat-thread-original-message__author">
|
||||
<span class="chat-thread-original-message__avatar">
|
||||
<ChatUserAvatar @user={{@message.user}} />
|
||||
</span>
|
||||
<span class="chat-thread-original-message__username">
|
||||
{{@message.user.username}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
import Component from "@glimmer/component";
|
||||
|
||||
export default class ChatThreadOriginalMessage extends Component {}
|
|
@ -0,0 +1,22 @@
|
|||
<DModalBody @title="chat.thread.settings">
|
||||
<div>
|
||||
<label for="thread-title" class="thread-title-label">
|
||||
{{i18n "chat.thread.title"}}
|
||||
</label>
|
||||
<Input
|
||||
name="thread-title"
|
||||
class="thread-title-input"
|
||||
@type="text"
|
||||
@value={{this.editedTitle}}
|
||||
/>
|
||||
</div>
|
||||
</DModalBody>
|
||||
|
||||
<div class="modal-footer">
|
||||
<DButton
|
||||
@class="btn-primary"
|
||||
@disabled={{this.buttonDisabled}}
|
||||
@action={{action "saveThread"}}
|
||||
@label="save"
|
||||
/>
|
||||
</div>
|
|
@ -0,0 +1,33 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class ChatThreadSettingsModalInner extends Component {
|
||||
@service chatApi;
|
||||
|
||||
@tracked editedTitle = this.args.thread.title || "";
|
||||
@tracked saving = false;
|
||||
|
||||
get buttonDisabled() {
|
||||
return this.saving;
|
||||
}
|
||||
|
||||
@action
|
||||
saveThread() {
|
||||
this.saving = true;
|
||||
this.chatApi
|
||||
.editThread(this.args.thread.channel.id, this.args.thread.id, {
|
||||
title: this.editedTitle,
|
||||
})
|
||||
.then(() => {
|
||||
this.args.thread.title = this.editedTitle;
|
||||
this.args.closeModal();
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import Controller from "@ember/controller";
|
||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
|
||||
export default class ChatThreadSettingsModalController extends Controller.extend(
|
||||
ModalFunctionality
|
||||
) {
|
||||
thread = null;
|
||||
}
|
|
@ -16,6 +16,7 @@ import { TrackedObject } from "@ember-compat/tracked-built-ins";
|
|||
export default class ChatThreadsManager {
|
||||
@service chatSubscriptionsManager;
|
||||
@service chatApi;
|
||||
@service chat;
|
||||
@service currentUser;
|
||||
@tracked _cached = new TrackedObject();
|
||||
|
||||
|
@ -34,6 +35,18 @@ export default class ChatThreadsManager {
|
|||
}
|
||||
}
|
||||
|
||||
async index(channelId) {
|
||||
return this.#loadIndex(channelId).then((result) => {
|
||||
const threads = result.threads.map((thread) => {
|
||||
return this.chat.activeChannel.threadsManager.store(
|
||||
this.chat.activeChannel,
|
||||
thread
|
||||
);
|
||||
});
|
||||
return { threads, meta: result.meta };
|
||||
});
|
||||
}
|
||||
|
||||
get threads() {
|
||||
return Object.values(this._cached);
|
||||
}
|
||||
|
@ -73,4 +86,8 @@ export default class ChatThreadsManager {
|
|||
#findStale(id) {
|
||||
return this._cached[id];
|
||||
}
|
||||
|
||||
async #loadIndex(channelId) {
|
||||
return this.chatApi.threads(channelId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ export default class ChatMessage {
|
|||
@tracked thread;
|
||||
@tracked threadReplyCount;
|
||||
@tracked manager = null;
|
||||
@tracked threadTitle = null;
|
||||
|
||||
@tracked _cooked;
|
||||
|
||||
|
@ -64,6 +65,7 @@ export default class ChatMessage {
|
|||
this.availableFlags = args.availableFlags || args.available_flags;
|
||||
this.hidden = args.hidden;
|
||||
this.threadReplyCount = args.threadReplyCount || args.thread_reply_count;
|
||||
this.threadTitle = args.threadTitle || args.thread_title;
|
||||
this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event;
|
||||
this.createdAt = args.createdAt || args.created_at;
|
||||
this.deletedAt = args.deletedAt || args.deleted_at;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
|
||||
import User from "discourse/models/user";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import guid from "pretty-text/guid";
|
||||
|
@ -22,6 +21,7 @@ export default class ChatThread {
|
|||
@tracked channel;
|
||||
@tracked originalMessage;
|
||||
@tracked threadMessageBusLastId;
|
||||
@tracked replyCount;
|
||||
|
||||
messagesManager = new ChatMessagesManager(getOwner(this));
|
||||
|
||||
|
@ -32,6 +32,7 @@ export default class ChatThread {
|
|||
this.status = args.status;
|
||||
this.draft = args.draft;
|
||||
this.staged = args.staged;
|
||||
this.replyCount = args.reply_count;
|
||||
this.originalMessage = ChatMessage.create(channel, args.original_message);
|
||||
}
|
||||
|
||||
|
@ -64,12 +65,4 @@ export default class ChatThread {
|
|||
get escapedTitle() {
|
||||
return escapeExpression(this.title);
|
||||
}
|
||||
|
||||
#initUserModel(user) {
|
||||
if (!user || user instanceof User) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return User.create(user);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,10 @@ export default class ChatChannelThread extends DiscourseRoute {
|
|||
this.chatChannelThreadPane.close();
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.chatChannelThreadPane.open(this.currentModel);
|
||||
}
|
||||
|
||||
beforeModel(transition) {
|
||||
const channel = this.modelFor("chat.channel");
|
||||
|
||||
|
@ -54,8 +58,4 @@ export default class ChatChannelThread extends DiscourseRoute {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterModel(model) {
|
||||
this.chatChannelThreadPane.open(model);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatChannelThreads extends DiscourseRoute {
|
||||
@service router;
|
||||
@service chatChannelThreadListPane;
|
||||
|
||||
deactivate() {
|
||||
this.chatChannelThreadListPane.close();
|
||||
}
|
||||
|
||||
beforeModel(transition) {
|
||||
const channel = this.modelFor("chat.channel");
|
||||
|
||||
if (!channel.threadingEnabled) {
|
||||
transition.abort();
|
||||
this.router.transitionTo("chat.channel", ...channel.routeModels);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.chatChannelThreadListPane.open();
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ export default class ChatRoute extends DiscourseRoute {
|
|||
const INTERCEPTABLE_ROUTES = [
|
||||
"chat.channel",
|
||||
"chat.channel.thread",
|
||||
"chat.channel.threads",
|
||||
"chat.channel.index",
|
||||
"chat.channel.near-message",
|
||||
"chat.channel-legacy",
|
||||
|
|
|
@ -48,6 +48,19 @@ export default class ChatApi extends Service {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all threads for a channel.
|
||||
* For now we only get the 50 threads ordered
|
||||
* by the last message sent by the user then the
|
||||
* thread creation date, later we will paginate
|
||||
* and add filters.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
threads(channelId) {
|
||||
return this.#getRequest(`/channels/${channelId}/threads`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all accessible category channels of the current user.
|
||||
* @returns {Collection}
|
||||
|
@ -421,6 +434,18 @@ export default class ChatApi extends Service {
|
|||
return this.#putRequest(`/channels/${channelId}/threads/${threadId}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings of a thread.
|
||||
*
|
||||
* @param {number} channelId - The ID of the channel for the thread being edited.
|
||||
* @param {number} threadId - The ID of the thread being edited.
|
||||
* @param {object} data - Params of the edit.
|
||||
* @param {string} data.title - The new title for the thread.
|
||||
*/
|
||||
editThread(channelId, threadId, data) {
|
||||
return this.#putRequest(`/channels/${channelId}/threads/${threadId}`, data);
|
||||
}
|
||||
|
||||
get #basePath() {
|
||||
return "/chat/api";
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import ChatComposer from "./chat-composer";
|
||||
import { next } from "@ember/runloop";
|
||||
|
||||
export default class ChatChannelComposer extends ChatComposer {
|
||||
@service chat;
|
||||
|
@ -25,17 +24,10 @@ export default class ChatChannelComposer extends ChatComposer {
|
|||
message.thread = thread;
|
||||
}
|
||||
|
||||
this.router
|
||||
.transitionTo("chat.channel.thread", ...thread.routeModels)
|
||||
.finally(() => this._setReplyToAfterTransition(message));
|
||||
this.chat.activeMessage = null;
|
||||
this.router.transitionTo("chat.channel.thread", ...thread.routeModels);
|
||||
} else {
|
||||
this.message.inReplyTo = message;
|
||||
}
|
||||
}
|
||||
|
||||
_setReplyToAfterTransition(message) {
|
||||
next(() => {
|
||||
this.chatChannelThreadComposer.replyTo(message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSub
|
|||
if (data.replies_count) {
|
||||
message.threadReplyCount = data.replies_count;
|
||||
}
|
||||
message.threadTitle = data.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,11 +10,4 @@ export default class ChatChannelThreadComposer extends ChatComposer {
|
|||
});
|
||||
this.message.thread = thread;
|
||||
}
|
||||
|
||||
@action
|
||||
replyTo(message) {
|
||||
this.chat.activeMessage = null;
|
||||
this.message.thread = message.thread;
|
||||
this.message.inReplyTo = message;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import Service, { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatChannelThreadListPane extends Service {
|
||||
@service chat;
|
||||
@service chatStateManager;
|
||||
|
||||
close() {
|
||||
this.chatStateManager.closeSidePanel();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.chat.activeMessage = null;
|
||||
this.chatStateManager.openSidePanel();
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import { tracked } from "@glimmer/tracking";
|
|||
import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel";
|
||||
import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel";
|
||||
import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread";
|
||||
import ChatDrawerThreads from "discourse/plugins/chat/discourse/components/chat-drawer/threads";
|
||||
import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index";
|
||||
|
||||
const COMPONENTS_MAP = {
|
||||
|
@ -17,6 +18,14 @@ const COMPONENTS_MAP = {
|
|||
};
|
||||
},
|
||||
},
|
||||
"chat.channel.threads": {
|
||||
name: ChatDrawerThreads,
|
||||
extractParams: (route) => {
|
||||
return {
|
||||
channelId: route.parent.params.channelId,
|
||||
};
|
||||
},
|
||||
},
|
||||
chat: { name: ChatDrawerIndex },
|
||||
"chat.channel.near-message": {
|
||||
name: ChatDrawerChannel,
|
||||
|
@ -42,8 +51,20 @@ export default class ChatDrawerRouter extends Service {
|
|||
@service router;
|
||||
@tracked component = null;
|
||||
@tracked params = null;
|
||||
@tracked history = [];
|
||||
|
||||
get previousRouteName() {
|
||||
if (this.history.length > 1) {
|
||||
return this.history[this.history.length - 2];
|
||||
}
|
||||
}
|
||||
|
||||
stateFor(route) {
|
||||
this.history.push(route.name);
|
||||
if (this.history.length > 10) {
|
||||
this.history.shift();
|
||||
}
|
||||
|
||||
const component = COMPONENTS_MAP[route.name];
|
||||
this.params = component?.extractParams?.(route) || route.params;
|
||||
this.component = component?.name || ChatDrawerIndex;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<Chat::Thread::List @channel={{this.model}} @includeHeader={{true}} />
|
|
@ -0,0 +1,4 @@
|
|||
<Chat::Thread::SettingsModalInner
|
||||
@thread={{this.thread}}
|
||||
@closeModal={{route-action "closeModal"}}
|
||||
/>
|
|
@ -8,7 +8,8 @@
|
|||
width: 100%;
|
||||
min-width: 250px;
|
||||
|
||||
.open-drawer-btn {
|
||||
.open-drawer-btn,
|
||||
.open-thread-list-btn {
|
||||
color: var(--primary-low-mid);
|
||||
|
||||
&:visited {
|
||||
|
@ -24,6 +25,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
.open-thread-list-btn {
|
||||
.d-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.discourse-touch & {
|
||||
background: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
.discourse-touch & {
|
||||
background: var(--secondary-very-high) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
|
|
|
@ -15,10 +15,13 @@
|
|||
&__replies-count {
|
||||
color: var(--primary-medium);
|
||||
font-size: var(--font-down-1);
|
||||
max-width: 60px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__view-thread {
|
||||
font-size: var(--font-down-1);
|
||||
flex: 1;
|
||||
|
||||
.chat-message-thread-indicator:hover & {
|
||||
text-decoration: underline;
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
@mixin thread-list-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
background-color: var(--d-selected);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.chat-thread-list-item {
|
||||
@include thread-list-item;
|
||||
cursor: pointer;
|
||||
|
||||
.touch & {
|
||||
&:active {
|
||||
background-color: var(--d-hover);
|
||||
border: 1px solid var(--primary-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.no-touch & {
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--d-hover);
|
||||
border: 1px solid var(--primary-medium);
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__open-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
|
||||
&:hover,
|
||||
&:visited {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
.chat-thread-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
&__items {
|
||||
overflow-y: scroll;
|
||||
@include chat-scrollbar();
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chat-thread-list-item {
|
||||
margin: 0.5rem 0.25rem 0.5rem 0.5rem;
|
||||
|
||||
& + .chat-thread-list-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__no-threads {
|
||||
@include thread-list-item;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
.chat-thread-original-message {
|
||||
display: flex;
|
||||
margin: 0.5rem 0rem;
|
||||
|
||||
&__inner-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__excerpt {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
padding: 0.25rem 0.25rem 0.25rem 0;
|
||||
}
|
||||
}
|
|
@ -47,3 +47,6 @@
|
|||
@import "full-page-chat-header";
|
||||
@import "incoming-chat-webhooks";
|
||||
@import "reviewable-chat-message";
|
||||
@import "chat-thread-list-item";
|
||||
@import "chat-thread-list";
|
||||
@import "chat-thread-original-message";
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.chat-thread-settings-modal-modal {
|
||||
.modal-inner-container {
|
||||
width: 98%;
|
||||
|
||||
.thread-title-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
.chat-thread-list {
|
||||
&__items {
|
||||
.chat-thread-list-item {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,3 +9,5 @@
|
|||
@import "chat-composer-upload";
|
||||
@import "chat-side-panel";
|
||||
@import "chat-thread";
|
||||
@import "chat-threads-list";
|
||||
@import "chat-thread-settings-modal";
|
||||
|
|
|
@ -250,6 +250,7 @@ en:
|
|||
save: "Save"
|
||||
select: "Select"
|
||||
return_to_list: "Return to channels list"
|
||||
return_to_threads_list: "Return to ongoing discussions"
|
||||
scroll_to_bottom: "Scroll to bottom"
|
||||
scroll_to_new_messages: "See new messages"
|
||||
sound:
|
||||
|
@ -541,15 +542,21 @@ en:
|
|||
no_results: "No results"
|
||||
|
||||
thread:
|
||||
title: "Title"
|
||||
view_thread: View thread
|
||||
default_title: "Thread #%{thread_id}"
|
||||
replies:
|
||||
one: "%{count} reply"
|
||||
other: "%{count} replies"
|
||||
label: Thread
|
||||
close: "Close Thread"
|
||||
original_message:
|
||||
started_by: "Started by"
|
||||
settings: "Settings"
|
||||
threads:
|
||||
started_by: "Started by"
|
||||
open: "Open Thread"
|
||||
list: "Ongoing discussions"
|
||||
none: "You are not participating in any threads in this channel."
|
||||
|
||||
draft_channel_screen:
|
||||
header: "New Message"
|
||||
|
|
|
@ -28,6 +28,8 @@ Chat::Engine.routes.draw do
|
|||
# Hints for JIT warnings.
|
||||
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
|
||||
|
||||
get "/channels/:channel_id/threads" => "channel_threads#index"
|
||||
put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update"
|
||||
get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show"
|
||||
put "/channels/:channel_id/threads/:thread_id/read" => "thread_reads#update"
|
||||
|
||||
|
|
|
@ -42,6 +42,13 @@ module Chat
|
|||
is_staff?
|
||||
end
|
||||
|
||||
# The only part of the thread that can be changed is the title
|
||||
# so this isn't too dangerous, if we end up wanting to change
|
||||
# more things in future we may want to re-evaluate to be staff-only here.
|
||||
def can_edit_thread?(thread)
|
||||
is_staff? || thread.original_message_user_id == @user.id
|
||||
end
|
||||
|
||||
def can_move_chat_messages?(channel)
|
||||
can_moderate_chat?(channel.chatable)
|
||||
end
|
||||
|
|
|
@ -142,9 +142,14 @@ Fabricator(:chat_thread, class_name: "Chat::Thread") do
|
|||
end
|
||||
|
||||
transient :channel
|
||||
transient :original_message_user
|
||||
|
||||
original_message do |attrs|
|
||||
Fabricate(:chat_message, chat_channel: attrs[:channel] || Fabricate(:chat_channel))
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
chat_channel: attrs[:channel] || Fabricate(:chat_channel),
|
||||
user: attrs[:original_message_user] || Fabricate(:user),
|
||||
)
|
||||
end
|
||||
|
||||
after_create { |thread| thread.original_message.update!(thread_id: thread.id) }
|
||||
|
|
|
@ -49,6 +49,7 @@ RSpec.describe Jobs::Chat::UpdateThreadReplyCount do
|
|||
"original_message_id" => thread.original_message_id,
|
||||
"replies_count" => 2,
|
||||
"type" => "update_thread_original_message",
|
||||
"title" => thread.title,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
|
|
@ -27,7 +27,7 @@ module ChatSystemHelpers
|
|||
Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user)
|
||||
end
|
||||
|
||||
def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4)
|
||||
def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4, thread_attrs: {})
|
||||
last_user = nil
|
||||
last_message = nil
|
||||
|
||||
|
@ -50,6 +50,7 @@ module ChatSystemHelpers
|
|||
end
|
||||
|
||||
last_message.thread.set_replies_count_cache(messages_count - 1, update_db: true)
|
||||
last_message.thread.update!(thread_attrs) if thread_attrs.any?
|
||||
last_message.thread
|
||||
end
|
||||
end
|
||||
|
|
|
@ -92,4 +92,137 @@ RSpec.describe Chat::Api::ChannelThreadsController do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
fab!(:thread_1) { Fabricate(:chat_thread, channel: public_channel) }
|
||||
fab!(:thread_2) { Fabricate(:chat_thread, channel: public_channel) }
|
||||
fab!(:thread_3) { Fabricate(:chat_thread, channel: public_channel) }
|
||||
fab!(:message_1) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
chat_channel: public_channel,
|
||||
thread: thread_1,
|
||||
created_at: 10.minutes.ago,
|
||||
)
|
||||
end
|
||||
fab!(:message_2) do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
chat_channel: public_channel,
|
||||
thread: thread_3,
|
||||
created_at: 2.seconds.ago,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns the threads the user has sent messages in for the channel" do
|
||||
get "/chat/api/channels/#{public_channel.id}/threads"
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["threads"].map { |thread| thread["id"] }).to eq(
|
||||
[thread_3.id, thread_1.id],
|
||||
)
|
||||
end
|
||||
|
||||
context "when the channel is not accessible to the useer" do
|
||||
before do
|
||||
public_channel.update!(chatable: Fabricate(:private_category, group: Fabricate(:group)))
|
||||
end
|
||||
|
||||
it "returns 404" do
|
||||
get "/chat/api/channels/#{public_channel.id}/threads"
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when channel does not have threading enabled" do
|
||||
before { public_channel.update!(threading_enabled: false) }
|
||||
|
||||
it "returns 404" do
|
||||
get "/chat/api/channels/#{public_channel.id}/threads"
|
||||
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/#{public_channel.id}/threads"
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "update" do
|
||||
let(:title) { "New title" }
|
||||
let(:params) { { title: title } }
|
||||
fab!(:thread) do
|
||||
Fabricate(:chat_thread, channel: public_channel, original_message_user: current_user)
|
||||
end
|
||||
|
||||
context "when thread does not exist" do
|
||||
it "returns 404" do
|
||||
thread.destroy!
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when thread exists" do
|
||||
it "updates the title" do
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(200)
|
||||
expect(thread.reload.title).to eq(title)
|
||||
end
|
||||
|
||||
context "when user cannot view the channel" do
|
||||
before { thread.update!(channel: Fabricate(:private_category_channel)) }
|
||||
|
||||
it "returns 403" do
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the user is not the original message user" do
|
||||
before { thread.update!(original_message_user: Fabricate(:user)) }
|
||||
|
||||
it "returns 403" do
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the title is too long" do
|
||||
let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" }
|
||||
|
||||
it "returns 400" do
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(400)
|
||||
expect(response.parsed_body["errors"]).to eq(
|
||||
["Title is too long (maximum is #{Chat::Thread::MAX_TITLE_LENGTH} characters)"],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when channel does not have threading enabled" do
|
||||
before { public_channel.update!(threading_enabled: false) }
|
||||
|
||||
it "returns 404" do
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
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
|
||||
put "/chat/api/channels/#{thread.channel_id}/threads/#{thread.id}", params: params
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::LookupChannelThreads do
|
||||
describe Chat::LookupChannelThreads::Contract, type: :model do
|
||||
it { is_expected.to validate_presence_of :channel_id }
|
||||
end
|
||||
|
||||
describe ".call" do
|
||||
subject(:result) { described_class.call(params) }
|
||||
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
fab!(:thread_1) { Fabricate(:chat_thread, channel: channel) }
|
||||
fab!(:thread_2) { Fabricate(:chat_thread, channel: channel) }
|
||||
fab!(:thread_3) { Fabricate(:chat_thread, channel: channel) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:params) { { guardian: guardian, channel_id: thread_1.channel_id } }
|
||||
|
||||
context "when enable_experimental_chat_threaded_discussions is disabled" do
|
||||
before { SiteSetting.enable_experimental_chat_threaded_discussions = false }
|
||||
|
||||
it { is_expected.to fail_a_policy(:threaded_discussions_enabled) }
|
||||
end
|
||||
|
||||
context "when enable_experimental_chat_threaded_discussions is enabled" do
|
||||
before { SiteSetting.enable_experimental_chat_threaded_discussions = true }
|
||||
|
||||
context "when all steps pass" do
|
||||
before do
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
chat_channel: channel,
|
||||
thread: thread_1,
|
||||
created_at: 10.minutes.ago,
|
||||
)
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
chat_channel: channel,
|
||||
thread: thread_2,
|
||||
created_at: 1.day.ago,
|
||||
)
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
chat_channel: channel,
|
||||
thread: thread_3,
|
||||
created_at: 2.seconds.ago,
|
||||
)
|
||||
end
|
||||
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "returns the threads ordered by the last thread the current user posted in" do
|
||||
expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id])
|
||||
end
|
||||
|
||||
it "does not return threads where the original message is deleted" do
|
||||
thread_1.original_message.trash!
|
||||
expect(result.threads.map(&:id)).to eq([thread_3.id, thread_2.id])
|
||||
end
|
||||
|
||||
it "does not count deleted messages for sort order" do
|
||||
Chat::Message.find_by(user: current_user, thread: thread_3).trash!
|
||||
expect(result.threads.map(&:id)).to eq([thread_1.id, thread_2.id])
|
||||
end
|
||||
|
||||
it "does not return threads from the channel where the user has not sent a message" do
|
||||
Fabricate(:chat_thread, channel: channel)
|
||||
expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id])
|
||||
end
|
||||
|
||||
it "does not return threads from another channel" do
|
||||
thread_4 = Fabricate(:chat_thread)
|
||||
Fabricate(
|
||||
:chat_message,
|
||||
user: current_user,
|
||||
thread: thread_4,
|
||||
chat_channel: thread_4.channel,
|
||||
created_at: 2.seconds.ago,
|
||||
)
|
||||
expect(result.threads.map(&:id)).to eq([thread_3.id, thread_1.id, thread_2.id])
|
||||
end
|
||||
end
|
||||
|
||||
context "when params are not valid" do
|
||||
before { params.delete(:channel_id) }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
|
||||
context "when user cannot see channel" do
|
||||
fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) }
|
||||
|
||||
before do
|
||||
thread_1.update!(channel: private_channel)
|
||||
private_channel.update!(threading_enabled: true)
|
||||
end
|
||||
|
||||
it { is_expected.to fail_a_policy(:can_view_channel) }
|
||||
end
|
||||
|
||||
context "when threading is not enabled for the channel" do
|
||||
before { channel.update!(threading_enabled: false) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:threading_enabled_for_channel) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,106 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::UpdateThread do
|
||||
describe Chat::UpdateThread::Contract, type: :model do
|
||||
it { is_expected.to validate_presence_of :channel_id }
|
||||
it { is_expected.to validate_presence_of :thread_id }
|
||||
end
|
||||
|
||||
describe ".call" do
|
||||
subject(:result) { described_class.call(params) }
|
||||
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) }
|
||||
fab!(:thread) { Fabricate(:chat_thread, channel: channel, original_message_user: current_user) }
|
||||
fab!(:other_thread) { Fabricate(:chat_thread) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:title) { "some new title :D" }
|
||||
let(:params) do
|
||||
{ guardian: guardian, thread_id: thread.id, channel_id: thread.channel_id, title: title }
|
||||
end
|
||||
|
||||
context "when enable_experimental_chat_threaded_discussions is disabled" do
|
||||
before { SiteSetting.enable_experimental_chat_threaded_discussions = false }
|
||||
|
||||
it { is_expected.to fail_a_policy(:threaded_discussions_enabled) }
|
||||
end
|
||||
|
||||
context "when enable_experimental_chat_threaded_discussions is enabled" do
|
||||
before { SiteSetting.enable_experimental_chat_threaded_discussions = true }
|
||||
|
||||
context "when all steps pass" do
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "updates the title of the thread" do
|
||||
result
|
||||
expect(thread.reload.title).to eq(title)
|
||||
end
|
||||
|
||||
it "publishes a MessageBus message" do
|
||||
message =
|
||||
MessageBus
|
||||
.track_publish(Chat::Publisher.root_message_bus_channel(thread.channel_id)) { result }
|
||||
.first
|
||||
|
||||
expect(message.data["type"]).to eq("update_thread_original_message")
|
||||
expect(message.data["title"]).to eq(title)
|
||||
end
|
||||
end
|
||||
|
||||
context "when params are not valid" do
|
||||
before { params.delete(:thread_id) }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
|
||||
context "when title is too long" do
|
||||
let(:title) { "a" * Chat::Thread::MAX_TITLE_LENGTH + "a" }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
|
||||
context "when thread is not found because the channel ID differs" do
|
||||
before { params[:thread_id] = other_thread.id }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:thread) }
|
||||
end
|
||||
|
||||
context "when thread is not found" do
|
||||
before { thread.destroy! }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:thread) }
|
||||
end
|
||||
|
||||
context "when user cannot see channel" do
|
||||
before { thread.update!(channel: private_channel) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:can_view_channel) }
|
||||
end
|
||||
|
||||
context "when user is not the thread original message creator" do
|
||||
before { thread.update!(original_message_user: Fabricate(:user)) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:can_edit_thread) }
|
||||
end
|
||||
|
||||
context "when user is not the thread original message creator but they are staff" do
|
||||
before do
|
||||
thread.original_message.update!(user: Fabricate(:user))
|
||||
current_user.update!(admin: true)
|
||||
end
|
||||
|
||||
it { is_expected.not_to fail_a_policy(:can_edit_thread) }
|
||||
end
|
||||
|
||||
context "when threading is not enabled for the channel" do
|
||||
before { channel.update!(threading_enabled: false) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:threading_enabled_for_channel) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -120,6 +120,10 @@ module Chat
|
|||
FailWithInvalidModel.new(name)
|
||||
end
|
||||
|
||||
def fail_a_step(name = "model")
|
||||
FailStep.new(name)
|
||||
end
|
||||
|
||||
def inspect_steps(result)
|
||||
inspector = Chat::StepsInspector.new(result)
|
||||
puts "Steps:"
|
||||
|
|
|
@ -52,6 +52,10 @@ module PageObjects
|
|||
find(".open-drawer-btn").click
|
||||
end
|
||||
|
||||
def open_thread_list
|
||||
find(".open-thread-list-btn").click
|
||||
end
|
||||
|
||||
def has_message?(message)
|
||||
container = find(".chat-message-container[data-id=\"#{message.id}\"")
|
||||
container.has_content?(message.message)
|
||||
|
|
|
@ -14,6 +14,10 @@ module PageObjects
|
|||
def has_no_open_thread?
|
||||
!has_css?(".chat-side-panel .chat-thread")
|
||||
end
|
||||
|
||||
def has_open_thread_list?
|
||||
has_css?(".chat-side-panel .chat-thread-list")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PageObjects
|
||||
module Pages
|
||||
class ChatThreadList < PageObjects::Pages::Base
|
||||
def item_by_id(id)
|
||||
find(item_by_id_selector(id))
|
||||
end
|
||||
|
||||
def item_by_id_selector(id)
|
||||
".chat-thread-list__items .chat-thread-list-item[data-thread-id=\"#{id}\"]"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -42,6 +42,10 @@ module PageObjects
|
|||
def has_open_channel?(channel)
|
||||
has_css?("#{VISIBLE_DRAWER} .chat-channel[data-id='#{channel.id}']")
|
||||
end
|
||||
|
||||
def has_open_thread_list?
|
||||
has_css?("#{VISIBLE_DRAWER} .chat-thread-list")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
describe "Thread list in side panel | drawer", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
fab!(:other_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(:thread_page) { PageObjects::Pages::ChatThread.new }
|
||||
let(:thread_list_page) { PageObjects::Pages::ChatThreadList.new }
|
||||
let(:drawer_page) { PageObjects::Pages::ChatDrawer.new }
|
||||
|
||||
before do
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions = true
|
||||
chat_system_bootstrap(current_user, [channel])
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
context "when threading not enabled for the channel" do
|
||||
before { channel.update!(threading_enabled: false) }
|
||||
|
||||
it "does not show the thread list button in drawer header" do
|
||||
visit("/")
|
||||
chat_page.open_from_header
|
||||
drawer_page.open_channel(channel)
|
||||
expect(find(".chat-drawer-header__right-actions")).not_to have_css(".open-thread-list-btn")
|
||||
end
|
||||
end
|
||||
|
||||
context "when threading is enabled for the channel" do
|
||||
before { channel.update!(threading_enabled: true) }
|
||||
|
||||
fab!(:thread_1) do
|
||||
chat_thread_chain_bootstrap(
|
||||
channel: channel,
|
||||
users: [current_user, other_user],
|
||||
thread_attrs: {
|
||||
title: "favourite album?",
|
||||
},
|
||||
)
|
||||
end
|
||||
fab!(:thread_2) do
|
||||
chat_thread_chain_bootstrap(
|
||||
channel: channel,
|
||||
users: [current_user, other_user],
|
||||
thread_attrs: {
|
||||
title: "current event",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
it "opens the thread list from the header button" do
|
||||
visit("/")
|
||||
chat_page.open_from_header
|
||||
drawer_page.open_channel(channel)
|
||||
find(".open-thread-list-btn").click
|
||||
expect(drawer_page).to have_open_thread_list
|
||||
end
|
||||
|
||||
it "shows the titles of the threads the user is participating in" do
|
||||
visit("/")
|
||||
chat_page.open_from_header
|
||||
drawer_page.open_channel(channel)
|
||||
find(".open-thread-list-btn").click
|
||||
expect(drawer_page).to have_open_thread_list
|
||||
expect(thread_list_page).to have_content(thread_1.title)
|
||||
expect(thread_list_page).to have_content(thread_2.title)
|
||||
end
|
||||
|
||||
it "opens a thread" do
|
||||
visit("/")
|
||||
chat_page.open_from_header
|
||||
drawer_page.open_channel(channel)
|
||||
find(".open-thread-list-btn").click
|
||||
expect(drawer_page).to have_open_thread_list
|
||||
thread_list_page.item_by_id(thread_1.id).click
|
||||
expect(drawer_page).to have_open_thread(thread_1)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,120 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
describe "Thread list in side panel | full page", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
|
||||
fab!(:other_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(:thread_page) { PageObjects::Pages::ChatThread.new }
|
||||
let(:thread_list_page) { PageObjects::Pages::ChatThreadList.new }
|
||||
|
||||
before do
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions = true
|
||||
chat_system_bootstrap(current_user, [channel])
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
context "when there are no threads that the user is participating in" do
|
||||
it "shows a message" do
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(page).to have_content(I18n.t("js.chat.threads.none"))
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are threads that the user is participating in" do
|
||||
before { chat_system_user_bootstrap(user: other_user, channel: channel) }
|
||||
|
||||
fab!(:thread_1) do
|
||||
chat_thread_chain_bootstrap(channel: channel, users: [current_user, other_user])
|
||||
end
|
||||
fab!(:thread_2) do
|
||||
chat_thread_chain_bootstrap(channel: channel, users: [current_user, other_user])
|
||||
end
|
||||
|
||||
it "shows a default title for threads without a title" do
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(page).to have_content(I18n.t("js.chat.thread.default_title", thread_id: thread_1.id))
|
||||
end
|
||||
|
||||
it "shows the thread title with emoji" do
|
||||
thread_1.update!(title: "What is for dinner? :hamburger:")
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_content("What is for dinner?")
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_css("img.emoji[alt='hamburger']")
|
||||
end
|
||||
|
||||
it "shows an excerpt of the original message of the thread" do
|
||||
thread_1.original_message.update!(message: "This is a long message for the excerpt")
|
||||
thread_1.original_message.rebake!
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_content(
|
||||
"This is a long message for the excerpt",
|
||||
)
|
||||
end
|
||||
|
||||
it "shows the thread original message user username and avatar" do
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_css(
|
||||
".chat-thread-original-message__avatar .chat-user-avatar .chat-user-avatar-container img",
|
||||
)
|
||||
expect(
|
||||
thread_list_page.item_by_id(thread_1.id).find(".chat-thread-original-message__username"),
|
||||
).to have_content(thread_1.original_message.user.username)
|
||||
end
|
||||
|
||||
it "opens a thread" do
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
thread_list_page.item_by_id(thread_1.id).click
|
||||
expect(side_panel).to have_open_thread(thread_1)
|
||||
end
|
||||
|
||||
describe "updating the title of the thread" do
|
||||
let(:new_title) { "wow new title" }
|
||||
|
||||
def open_thread_list
|
||||
chat_page.visit_channel(channel)
|
||||
chat_page.open_thread_list
|
||||
expect(side_panel).to have_open_thread_list
|
||||
end
|
||||
|
||||
it "allows updating when user is admin" do
|
||||
current_user.update!(admin: true)
|
||||
open_thread_list
|
||||
thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings").click
|
||||
find(".thread-title-input").fill_in(with: new_title)
|
||||
find(".modal-footer .btn-primary").click
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_content(new_title)
|
||||
end
|
||||
|
||||
it "allows updating when user is same as the chat original message user" do
|
||||
thread_1.update!(original_message_user: current_user)
|
||||
thread_1.original_message.update!(user: current_user)
|
||||
open_thread_list
|
||||
thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings").click
|
||||
find(".thread-title-input").fill_in(with: new_title)
|
||||
find(".modal-footer .btn-primary").click
|
||||
expect(thread_list_page.item_by_id(thread_1.id)).to have_content(new_title)
|
||||
end
|
||||
|
||||
it "does not allow updating if user is neither admin nor original message user" do
|
||||
thread_1.update!(original_message_user: other_user)
|
||||
thread_1.original_message.update!(user: other_user)
|
||||
open_thread_list
|
||||
expect(
|
||||
thread_list_page.item_by_id(thread_1.id).find(".chat-thread-list-item__settings")[
|
||||
:disabled
|
||||
],
|
||||
).to eq("true")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue