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:
Joffrey JAFFEUX 2023-05-10 11:42:32 +02:00 committed by GitHub
parent 7a84fc3d9d
commit c6b43ce68b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 1512 additions and 64 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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() {

View File

@ -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}}

View File

@ -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;
});
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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}}

View File

@ -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();
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -0,0 +1,3 @@
import Component from "@glimmer/component";
export default class ChatThreadOriginalMessage extends Component {}

View File

@ -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>

View File

@ -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;
});
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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",

View File

@ -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";
}

View File

@ -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);
});
}
}

View File

@ -26,6 +26,7 @@ export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSub
if (data.replies_count) {
message.threadReplyCount = data.replies_count;
}
message.threadTitle = data.title;
}
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -0,0 +1 @@
<Chat::Thread::List @channel={{this.model}} @includeHeader={{true}} />

View File

@ -0,0 +1,4 @@
<Chat::Thread::SettingsModalInner
@thread={{this.thread}}
@closeModal={{route-action "closeModal"}}
/>

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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";

View File

@ -0,0 +1,9 @@
.chat-thread-settings-modal-modal {
.modal-inner-container {
width: 98%;
.thread-title-input {
width: 100%;
}
}
}

View File

@ -0,0 +1,7 @@
.chat-thread-list {
&__items {
.chat-thread-list-item {
margin: 0.5rem;
}
}
}

View File

@ -9,3 +9,5 @@
@import "chat-composer-upload";
@import "chat-side-panel";
@import "chat-thread";
@import "chat-threads-list";
@import "chat-thread-settings-modal";

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:"

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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