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:
parent
620f76cec1
commit
0c8f531909
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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}} />
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 can’t 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
|
|
@ -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
|
Loading…
Reference in New Issue