FEATURE: encourage users to set chat thread titles (#26617)

This change encourages users to title their threads to make it easier for other users to join in on conversations that matter to them.

The creator of the chat thread will receive a toast notification prompting them to add a thread title when on mobile and the thread has at least 5 sent replies.
This commit is contained in:
David Battersby 2024-04-29 17:20:01 +08:00 committed by GitHub
parent 620f76cec1
commit 0c8f531909
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 555 additions and 28 deletions

View File

@ -291,6 +291,7 @@ end
# watched_precedence_over_muted :boolean
# chat_separate_sidebar_mode :integer default(0), not null
# topics_unread_when_closed :boolean default(TRUE), not null
# show_thread_title_prompts :boolean default(TRUE), not null
#
# Indexes
#

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class Chat::Api::ChannelThreadsCurrentUserTitlePromptSeenController < Chat::ApiController
def update
with_service(Chat::MarkThreadTitlePromptSeen) do
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
on_model_not_found(:thread) { raise Discourse::NotFound }
on_success do
render_serialized(
result.membership,
Chat::BaseThreadMembershipSerializer,
root: "membership",
)
end
on_failure { render(json: failed_json, status: 422) }
on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)
end
end
end
end

View File

@ -42,8 +42,12 @@ module Chat
# as the last message in this case as a fallback.
before_create { self.last_message_id = self.original_message_id }
def add(user)
Chat::UserChatThreadMembership.find_or_create_by!(user: user, thread: self)
def add(user, notification_level: Chat::NotificationLevels.all[:tracking])
Chat::UserChatThreadMembership.find_or_create_by!(
user: user,
thread: self,
notification_level: notification_level,
)
end
def remove(user)

View File

@ -20,13 +20,14 @@ end
#
# Table name: user_chat_thread_memberships
#
# id :bigint not null, primary key
# user_id :bigint not null
# thread_id :bigint not null
# last_read_message_id :bigint
# notification_level :integer default("tracking"), not null
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint not null, primary key
# user_id :bigint not null
# thread_id :bigint not null
# last_read_message_id :bigint
# notification_level :integer default("tracking"), not null
# thread_title_prompt_seen :boolean default(false), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#

View File

@ -2,11 +2,15 @@
module Chat
class BaseThreadMembershipSerializer < ApplicationSerializer
attributes :notification_level, :thread_id, :last_read_message_id
attributes :notification_level, :thread_id, :last_read_message_id, :thread_title_prompt_seen
def notification_level
Chat::UserChatThreadMembership.notification_levels[object.notification_level] ||
Chat::UserChatThreadMembership.notification_levels["normal"]
end
def thread_title_prompt_seen
object.try(:thread_title_prompt_seen) || false
end
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Chat
# Marks the thread title prompt as seen for a specific user/thread
# Note: if the thread does not exist, it adds the user as a member of the thread
# before setting the thread title prompt.
#
# @example
# Chat::MarkThreadTitlePromptSeen.call(
# thread_id: 88,
# channel_id: 2,
# guardian: guardian,
# )
#
class MarkThreadTitlePromptSeen
include Service::Base
# @!method call(thread_id:, channel_id:, guardian:)
# @param [Integer] thread_id
# @param [Integer] channel_id
# @param [Guardian] guardian
# @return [Service::Base::Context]
contract
model :thread
policy :threading_enabled_for_channel
policy :can_view_channel
transaction { step :create_or_update_membership }
# @!visibility private
class Contract
attribute :thread_id, :integer
attribute :channel_id, :integer
validates :thread_id, :channel_id, presence: true
end
private
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 threading_enabled_for_channel(thread:)
thread.channel.threading_enabled
end
def create_or_update_membership(thread:, guardian:, contract:)
membership = thread.membership_for(guardian.user)
membership =
thread.add(
guardian.user,
notification_level: Chat::NotificationLevels.all[:normal],
) if !membership
membership.update!(thread_title_prompt_seen: true)
context.membership = membership
end
end
end

View File

@ -0,0 +1,114 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import I18n from "I18n";
import ThreadSettingsModal from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings";
import { THREAD_TITLE_PROMPT_THRESHOLD } from "discourse/plugins/chat/discourse/lib/chat-constants";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
export default class ShowThreadTitlePrompt extends Component {
@service chatApi;
@service modal;
@service toasts;
@service currentUser;
@service site;
toastText = {
title: I18n.t("chat.thread_title_toast.title"),
message: I18n.t("chat.thread_title_toast.message"),
dismissLabel: I18n.t("chat.thread_title_toast.dismiss_action"),
primaryLabel: I18n.t("chat.thread_title_toast.primary_action"),
};
constructor() {
super(...arguments);
next(() => {
if (this.canShowToast) {
this.show();
this.updateThreadTitlePrompt();
}
});
}
get membership() {
return this.args.thread.currentUserMembership;
}
@action
async updateThreadTitlePrompt() {
try {
const result = await this.chatApi.updateCurrentUserThreadTitlePrompt(
this.args.thread.channel.id,
this.args.thread.id
);
this.args.thread.currentUserMembership = UserChatThreadMembership.create(
result.membership
);
} catch (e) {
// eslint-disable-next-line no-console
console.log("Couldn't save thread title prompt status", e);
if (this.membership) {
this.membership.threadTitlePromptSeen = false;
}
}
}
@action
disableFutureThreadTitlePrompts() {
this.currentUser.set("user_option.show_thread_title_prompts", false);
this.currentUser.save();
}
get canShowToast() {
if (
this.site.desktopView ||
(this.args.thread.user_id !== this.currentUser.id &&
!this.currentUser.admin)
) {
return false;
}
const titleNotSet = this.args.thread.title === null;
const hasReplies =
this.args.thread.replyCount >= THREAD_TITLE_PROMPT_THRESHOLD;
const showPrompts = this.currentUser.user_option.show_thread_title_prompts;
const promptNotSeen = !this.membership?.threadTitlePromptSeen;
return titleNotSet && hasReplies && showPrompts && promptNotSeen;
}
show() {
this.toasts.default({
duration: 5000,
showProgressBar: true,
class: "thread-toast",
data: {
title: this.toastText.title,
message: this.toastText.message,
actions: [
{
label: this.toastText.dismissLabel,
class: "btn-link toast-hide",
action: (toast) => {
this.disableFutureThreadTitlePrompts();
toast.close();
},
},
{
label: this.toastText.primaryLabel,
class: "btn-primary toast-action",
action: (toast) => {
this.modal.show(ThreadSettingsModal, {
model: this.args.thread,
});
toast.close();
},
},
],
},
});
}
}

View File

@ -12,6 +12,7 @@ import { resetIdle } from "discourse/lib/desktop-notifications";
import { NotificationLevels } from "discourse/lib/notification-levels";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import ShowThreadTitlePrompt from "discourse/plugins/chat/discourse/components/chat-thread-title-prompt";
import firstVisibleMessageId from "discourse/plugins/chat/discourse/helpers/first-visible-message-id";
import ChatChannelThreadSubscriptionManager from "discourse/plugins/chat/discourse/lib/chat-channel-thread-subscription-manager";
import {
@ -583,6 +584,7 @@ export default class ChatThread extends Component {
{{/if}}
<ChatUploadDropZone @model={{@thread}} />
<ShowThreadTitlePrompt @thread={{@thread}} />
</div>
</template>
}

View File

@ -3,9 +3,11 @@ import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class ChatModalThreadSettings extends Component {
@ -22,6 +24,10 @@ export default class ChatModalThreadSettings extends Component {
return this.args.model;
}
get threadTitleLength() {
return this.editedTitle.length;
}
@action
saveThread() {
this.saving = true;
@ -45,18 +51,40 @@ export default class ChatModalThreadSettings extends Component {
@closeModal={{@closeModal}}
class="chat-modal-thread-settings"
@inline={{@inline}}
@title={{i18n "chat.thread.settings"}}
@title={{i18n "chat.thread_title_modal.title"}}
>
<:headerPrimaryAction>
<DButton
@disabled={{this.buttonDisabled}}
@action={{this.saveThread}}
@label="chat.save"
class="btn-transparent btn-primary"
/>
</:headerPrimaryAction>
<:body>
<label for="thread-title" class="thread-title-label">
{{i18n "chat.thread.title"}}
</label>
<Input
name="thread-title"
class="chat-modal-thread-settings__title-input"
maxlength="50"
placeholder={{i18n "chat.thread_title_modal.input_placeholder"}}
@type="text"
@value={{this.editedTitle}}
/>
<div class="thread-title-length">
<span>{{this.threadTitleLength}}</span>/50
</div>
<div class="discourse-ai-cta">
<p class="discourse-ai-cta__title">{{icon "info-circle"}}
{{i18n "chat.thread_title_modal.discourse_ai.title"}}</p>
<p class="discourse-ai-cta__description">{{htmlSafe
(i18n
"chat.thread_title_modal.discourse_ai.description"
url="<a href='https://www.discourse.org/ai' rel='noopener noreferrer' target='_blank'>Discourse AI</a>"
)
}}
</p>
</div>
</:body>
<:footer>
<DButton

View File

@ -1,4 +1,5 @@
import { hash } from "@ember/helper";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import SubTitle from "./sub-title";
@ -8,15 +9,21 @@ const ChatNavbarTitle = <template>
title={{@title}}
class={{concatClass "c-navbar__title" (if @showFullTitle "full-title")}}
>
{{#if (has-block)}}
<span class="c-navbar__title-text">{{if @icon (icon @icon)}}
{{@title}}</span>
{{yield (hash SubTitle=SubTitle)}}
{{#if @openThreadTitleModal}}
<DButton
class="c-navbar__title-text btn-transparent"
@icon={{@icon}}
@action={{@openThreadTitleModal}}
@translatedLabel={{@title}}
/>
{{else}}
<span class="c-navbar__title-text">{{if
@icon
(icon @icon)
}}{{@title}}</span>
<span class="c-navbar__title-text">
{{if @icon (icon @icon)}}
{{@title}}
</span>
{{/if}}
{{#if (has-block)}}
{{yield (hash SubTitle=SubTitle)}}
{{/if}}
</div>
</template>;

View File

@ -1,15 +1,18 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import noop from "discourse/helpers/noop";
import replaceEmoji from "discourse/helpers/replace-emoji";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import and from "truth-helpers/helpers/and";
import ThreadSettingsModal from "discourse/plugins/chat/discourse/components/chat/modal/thread-settings";
import Navbar from "discourse/plugins/chat/discourse/components/chat/navbar";
import ChatThreadHeaderUnreadIndicator from "discourse/plugins/chat/discourse/components/chat/thread/header-unread-indicator";
export default class ChatThreadHeader extends Component {
@service currentUser;
@service chatHistory;
@service modal;
@service site;
get backLink() {
@ -59,6 +62,18 @@ export default class ChatThreadHeader extends Component {
);
}
get openThreadTitleModal() {
if (
this.currentUser.admin ||
this.currentUser.id === this.args.thread?.originalMessage?.user?.id
) {
return () =>
this.modal.show(ThreadSettingsModal, { model: this.args.thread });
} else {
return noop;
}
}
<template>
<Navbar @showFullTitle={{@showFullTitle}} as |navbar|>
{{#if (and this.channel.threadingEnabled @thread)}}
@ -74,7 +89,10 @@ export default class ChatThreadHeader extends Component {
</navbar.BackButton>
{{/if}}
<navbar.Title @title={{replaceEmoji this.headerTitle}} />
<navbar.Title
@title={{replaceEmoji this.headerTitle}}
@openThreadTitleModal={{this.openThreadTitleModal}}
/>
<navbar.Actions as |action|>
<action.ThreadTrackingDropdown @thread={{@thread}} />
<action.ThreadSettingsButton @thread={{@thread}} />

View File

@ -11,6 +11,7 @@ const CHAT_ATTRS = [
"chat_enabled",
"only_chat_push_notifications",
"ignore_channel_wide_mention",
"show_thread_title_prompts",
"chat_sound",
"chat_email_frequency",
"chat_header_indicator_preference",

View File

@ -3,6 +3,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
const CHAT_ENABLED_FIELD = "chat_enabled";
const ONLY_CHAT_PUSH_NOTIFICATIONS_FIELD = "only_chat_push_notifications";
const IGNORE_CHANNEL_WIDE_MENTION = "ignore_channel_wide_mention";
const SHOW_THREAD_TITLE_PROMPTS = "show_thread_title_prompts";
const CHAT_SOUND = "chat_sound";
const CHAT_EMAIL_FREQUENCY = "chat_email_frequency";
const CHAT_HEADER_INDICATOR_PREFERENCE = "chat_header_indicator_preference";
@ -18,6 +19,7 @@ export default {
api.addSaveableUserOptionField(CHAT_ENABLED_FIELD);
api.addSaveableUserOptionField(ONLY_CHAT_PUSH_NOTIFICATIONS_FIELD);
api.addSaveableUserOptionField(IGNORE_CHANNEL_WIDE_MENTION);
api.addSaveableUserOptionField(SHOW_THREAD_TITLE_PROMPTS);
api.addSaveableUserOptionField(CHAT_SOUND);
api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY);
api.addSaveableUserOptionField(CHAT_HEADER_INDICATOR_PREFERENCE);

View File

@ -2,6 +2,7 @@ export const PAST = "past";
export const FUTURE = "future";
export const READ_INTERVAL_MS = 1000;
export const DEFAULT_MESSAGE_PAGE_SIZE = 50;
export const THREAD_TITLE_PROMPT_THRESHOLD = 5;
export const FOOTER_NAV_ROUTES = [
"chat.direct-messages",
"chat.channels",

View File

@ -8,10 +8,12 @@ export default class UserChatThreadMembership {
@tracked lastReadMessageId = null;
@tracked notificationLevel = null;
@tracked threadTitlePromptSeen = null;
constructor(args = {}) {
this.lastReadMessageId = args.last_read_message_id;
this.notificationLevel = args.notification_level;
this.threadTitlePromptSeen = args.thread_title_prompt_seen;
}
get isQuiet() {

View File

@ -373,6 +373,18 @@ export default class ChatApi extends Service {
);
}
/**
* Update thread title prompt of current user for a thread.
* @param {number} channelId - The ID of the channel.
* @param {number} threadId - The ID of the thread.
* @returns {Promise}
*/
updateCurrentUserThreadTitlePrompt(channelId, threadId) {
return this.#postRequest(
`/channels/${channelId}/threads/${threadId}/mark-thread-title-prompt-seen/me`
);
}
/**
* Saves a draft for the channel, which includes message contents and uploads.
* @param {number} channelId - The ID of the channel.

View File

@ -1,9 +1,27 @@
.modal-chat-thread-settings {
.modal-inner-container {
width: 98%;
.chat-modal-thread-settings {
.thread-title-length {
color: var(--primary-medium);
margin-bottom: 1rem;
text-align: right;
}
&__title-input {
width: 100%;
.discourse-ai-cta {
padding: 1rem 1.25rem;
border-radius: 0.25rem;
background-color: var(--primary-very-low);
border: 1px solid var(--primary-low);
margin-top: 5rem;
p {
margin: 0;
}
&__title {
font-weight: bold;
}
&__description {
font-size: var(--font-down-1);
}
}
}

View File

@ -7,3 +7,15 @@
padding: 10px 10px 0 10px;
}
}
.thread-toast {
.toast-hide {
font-size: var(--font-down-1);
}
.toast-action {
position: absolute;
bottom: 0;
right: 0;
}
}

View File

@ -18,6 +18,7 @@
@import "chat-channel-settings";
@import "chat-form";
@import "chat-modal-new-message";
@import "chat-modal-thread-settings";
@import "chat-navbar";
@import "chat-thread-list-header";
@import "chat-user-threads";

View File

@ -238,6 +238,9 @@ en:
ignore_channel_wide_mention:
title: "Ignore channel-wide mentions"
description: "Do not send notifications for channel-wide mentions (@here and @all)"
show_thread_title_prompts:
title: "Show thread title prompts"
description: "Show prompts to set a title for new threads"
open: "Open chat"
open_full_page: "Open full-screen chat"
close_full_page: "Close full-screen chat"
@ -634,6 +637,17 @@ en:
participants_other_count:
one: "+%{count}"
other: "+%{count}"
thread_title_toast:
title: "Set a thread title"
message: "Help others discover this conversation."
dismiss_action: "Don't show again"
primary_action: "Set title"
thread_title_modal:
title: "Set thread title"
input_placeholder: "Tell others what this conversation is about..."
discourse_ai:
title: "Generate thread titles automatically"
description: "Check out %{url} to learn more about this and other enhancements to your Discourse experience."
threads:
open: "Open Thread"
list: "Threads"

View File

@ -49,6 +49,8 @@ Chat::Engine.routes.draw do
post "/channels/:channel_id/threads/:thread_id/drafts" => "channels_threads_drafts#create"
put "/channels/:channel_id/threads/:thread_id/notifications-settings/me" =>
"channel_threads_current_user_notifications_settings#update"
post "/channels/:channel_id/threads/:thread_id/mark-thread-title-prompt-seen/me" =>
"channel_threads_current_user_title_prompt_seen#update"
# TODO (martin) Remove this when we refactor the DM channel creation to happen
# via message creation in a different API controller.

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddThreadTitlePromptToUserChatThreadMemberships < ActiveRecord::Migration[7.0]
def change
add_column :user_chat_thread_memberships,
:thread_title_prompt_seen,
:boolean,
default: false,
null: false
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddShowThreadTitlePromptsToUserOptions < ActiveRecord::Migration[7.0]
def change
add_column :user_options, :show_thread_title_prompts, :boolean, default: true, null: false
end
end

View File

@ -45,6 +45,10 @@ module Chat
base.chat_separate_sidebar_mode,
prefix: "chat_separate_sidebar_mode"
end
if !base.method_defined?(:show_thread_title_prompts?)
base.attribute :show_thread_title_prompts, :boolean, default: true
end
end
end
end

View File

@ -54,6 +54,7 @@ after_initialize do
UserUpdater::OPTION_ATTR.push(:only_chat_push_notifications)
UserUpdater::OPTION_ATTR.push(:chat_sound)
UserUpdater::OPTION_ATTR.push(:ignore_channel_wide_mention)
UserUpdater::OPTION_ATTR.push(:show_thread_title_prompts)
UserUpdater::OPTION_ATTR.push(:chat_email_frequency)
UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference)
UserUpdater::OPTION_ATTR.push(:chat_separate_sidebar_mode)
@ -251,6 +252,12 @@ after_initialize do
object.ignore_channel_wide_mention
end
add_to_serializer(:user_option, :show_thread_title_prompts) { object.show_thread_title_prompts }
add_to_serializer(:current_user_option, :show_thread_title_prompts) do
object.show_thread_title_prompts
end
add_to_serializer(:user_option, :chat_email_frequency) { object.chat_email_frequency }
add_to_serializer(:user_option, :chat_header_indicator_preference) do

View File

@ -6,4 +6,9 @@ RSpec.describe UserOption do
expect(described_class.new.chat_separate_sidebar_mode).to eq("default")
end
end
describe "#show_thread_title_prompts" do
it "is present" do
expect(described_class.new.show_thread_title_prompts).to eq(true)
end
end
end

View File

@ -77,6 +77,7 @@ RSpec.describe Chat::Api::ChannelThreadsCurrentUserNotificationsSettingsControll
"notification_level" => Chat::UserChatThreadMembership.notification_levels[:normal],
"thread_id" => thread.id,
"last_read_message_id" => last_reply.id,
"thread_title_prompt_seen" => false,
)
end
end

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
RSpec.describe Chat::Api::ChannelThreadsCurrentUserTitlePromptSeenController do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel, threading_enabled: true) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1, original_message: message_1) }
fab!(:thread_reply) { Fabricate(:chat_message, thread: thread_1) }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
end
describe "#update" do
context "when not signed in" do
it "returns 403" do
post "/chat/api/channels/#{channel_1.id}/threads/#{thread_1.id}/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(403)
end
end
context "when signed in" do
before do
channel_1.add(current_user)
sign_in(current_user)
end
context "when invalid" do
it "returns 404 if channel id is not found" do
post "/chat/api/channels/-/threads/#{thread_1.id}/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(404)
end
it "returns 404 if thread id is not found" do
post "/chat/api/channels/#{channel_1.id}/threads/-/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(404)
end
it "returns 404 if channel threading is not enabled" do
channel_1.update!(threading_enabled: false)
post "/chat/api/channels/#{channel_1.id}/threads/#{thread_1.id}/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(404)
end
it "returns 404 if user cant view channel" do
channel = Fabricate(:private_category_channel)
thread = Fabricate(:chat_thread, channel: channel)
post "/chat/api/channels/#{channel.id}/threads/#{thread.id}/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(404)
end
end
context "when valid" do
it "updates thread_title_prompt_seen" do
membership = thread_1.membership_for(current_user)
expect(membership.thread_title_prompt_seen).to eq(false)
post "/chat/api/channels/#{channel_1.id}/threads/#{thread_1.id}/mark-thread-title-prompt-seen/me"
expect(response.status).to eq(200)
expect(membership.reload.thread_title_prompt_seen).to eq(true)
end
it "creates a membership if none found" do
random_thread = Fabricate(:chat_thread, channel: channel_1)
expect do
post "/chat/api/channels/#{channel_1.id}/threads/#{random_thread.id}/mark-thread-title-prompt-seen/me"
end.to change { Chat::UserChatThreadMembership.count }.by(1)
expect(response.status).to eq(200)
end
end
end
end
end

View File

@ -0,0 +1,83 @@
# frozen_string_literal: true
RSpec.describe Chat::MarkThreadTitlePromptSeen do
describe Chat::MarkThreadTitlePromptSeen::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) do
Fabricate(:private_category_channel, group: Fabricate(:group), threading_enabled: true)
end
fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
fab!(:last_reply) { Fabricate(:chat_message, thread: thread, chat_channel: channel) }
let(:guardian) { Guardian.new(current_user) }
let(:params) { { guardian: guardian, thread_id: thread.id, channel_id: thread.channel_id } }
before { thread.update!(last_message: last_reply) }
context "when all steps pass" do
it "sets the service result as successful" do
expect(result).to be_a_success
end
context "when the user is a member of the thread" do
fab!(:membership) { thread.add(current_user) }
it "updates the thread_title_prompt_seen" do
expect { result }.not_to change { Chat::UserChatThreadMembership.count }
expect(membership.reload.thread_title_prompt_seen).to eq(true)
end
end
context "when the user is not a member of the thread yet" do
it "creates the membership and updates thread_title_prompt_seen" do
expect { result }.to change { Chat::UserChatThreadMembership.count }.by(1)
expect(result.membership.thread_title_prompt_seen).to eq(true)
end
end
end
context "when thread_id is missing" do
before { params.delete(:thread_id) }
it { is_expected.to fail_a_contract }
end
context "when channel_id is missing" do
before { params.delete(:channel_id) }
it { is_expected.to fail_a_contract }
end
context "when thread is not found because the channel ID differs" do
before { params[:thread_id] = Fabricate(:chat_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 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
context "when user cannot see channel" do
before { thread.update!(channel_id: private_channel.id) }
it { is_expected.to fail_a_policy(:can_view_channel) }
end
end
end