From 0c8f5319091599f5af39ccffd9ba121d61ea7b17 Mon Sep 17 00:00:00 2001 From: David Battersby Date: Mon, 29 Apr 2024 17:20:01 +0800 Subject: [PATCH] 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. --- app/models/user_option.rb | 1 + ...rrent_user_title_prompt_seen_controller.rb | 22 ++++ plugins/chat/app/models/chat/thread.rb | 8 +- .../chat/user_chat_thread_membership.rb | 15 +-- .../chat/base_thread_membership_serializer.rb | 6 +- .../chat/mark_thread_title_prompt_seen.rb | 63 ++++++++++ .../components/chat-thread-title-prompt.js | 114 ++++++++++++++++++ .../discourse/components/chat-thread.gjs | 2 + .../components/chat/modal/thread-settings.gjs | 36 +++++- .../components/chat/navbar/title.gjs | 23 ++-- .../components/chat/thread/header.gjs | 20 ++- .../discourse/controllers/preferences-chat.js | 1 + .../initializers/chat-user-options.js | 2 + .../discourse/lib/chat-constants.js | 1 + .../models/user-chat-thread-membership.js | 2 + .../discourse/services/chat-api.js | 12 ++ .../mobile/chat-modal-thread-settings.scss | 28 ++++- .../stylesheets/mobile/chat-thread.scss | 12 ++ .../chat/assets/stylesheets/mobile/index.scss | 1 + plugins/chat/config/locales/client.en.yml | 14 +++ plugins/chat/config/routes.rb | 2 + ..._prompt_to_user_chat_thread_memberships.rb | 11 ++ ...ow_thread_title_prompts_to_user_options.rb | 7 ++ .../chat/lib/chat/user_option_extension.rb | 4 + plugins/chat/plugin.rb | 7 ++ plugins/chat/spec/models/user_option_spec.rb | 5 + ...r_notification_settings_controller_spec.rb | 1 + ..._user_title_prompt_seen_controller_spec.rb | 80 ++++++++++++ .../mark_thread_title_prompt_seen_spec.rb | 83 +++++++++++++ 29 files changed, 555 insertions(+), 28 deletions(-) create mode 100644 plugins/chat/app/controllers/chat/api/channel_threads_current_user_title_prompt_seen_controller.rb create mode 100644 plugins/chat/app/services/chat/mark_thread_title_prompt_seen.rb create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-thread-title-prompt.js create mode 100644 plugins/chat/db/migrate/20240409060201_add_thread_title_prompt_to_user_chat_thread_memberships.rb create mode 100644 plugins/chat/db/migrate/20240409093348_add_show_thread_title_prompts_to_user_options.rb create mode 100644 plugins/chat/spec/requests/chat/api/channel_threads_current_user_title_prompt_seen_controller_spec.rb create mode 100644 plugins/chat/spec/services/chat/mark_thread_title_prompt_seen_spec.rb diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 95edf4709f4..18024366a12 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -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 # diff --git a/plugins/chat/app/controllers/chat/api/channel_threads_current_user_title_prompt_seen_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_current_user_title_prompt_seen_controller.rb new file mode 100644 index 00000000000..eac3244c5fb --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channel_threads_current_user_title_prompt_seen_controller.rb @@ -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 diff --git a/plugins/chat/app/models/chat/thread.rb b/plugins/chat/app/models/chat/thread.rb index e9482796a28..0ced16998ca 100644 --- a/plugins/chat/app/models/chat/thread.rb +++ b/plugins/chat/app/models/chat/thread.rb @@ -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) diff --git a/plugins/chat/app/models/chat/user_chat_thread_membership.rb b/plugins/chat/app/models/chat/user_chat_thread_membership.rb index 20bfd426b90..66fefac791b 100644 --- a/plugins/chat/app/models/chat/user_chat_thread_membership.rb +++ b/plugins/chat/app/models/chat/user_chat_thread_membership.rb @@ -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 # diff --git a/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb index 1f85493126e..6a87b4d1f2a 100644 --- a/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb +++ b/plugins/chat/app/serializers/chat/base_thread_membership_serializer.rb @@ -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 diff --git a/plugins/chat/app/services/chat/mark_thread_title_prompt_seen.rb b/plugins/chat/app/services/chat/mark_thread_title_prompt_seen.rb new file mode 100644 index 00000000000..bd102747818 --- /dev/null +++ b/plugins/chat/app/services/chat/mark_thread_title_prompt_seen.rb @@ -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 diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread-title-prompt.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread-title-prompt.js new file mode 100644 index 00000000000..7e3af0ca180 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread-title-prompt.js @@ -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(); + }, + }, + ], + }, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs index b54feb57eb1..c5abb5dc289 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs @@ -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}} + } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.gjs b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.gjs index 6d87ae3b391..a4cb248c097 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/modal/thread-settings.gjs @@ -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> + + <:body> - +
+ {{this.threadTitleLength}}/50 +
+ +
+

{{icon "info-circle"}} + {{i18n "chat.thread_title_modal.discourse_ai.title"}}

+

{{htmlSafe + (i18n + "chat.thread_title_modal.discourse_ai.description" + url="Discourse AI" + ) + }} +

+
<:footer> title={{@title}} class={{concatClass "c-navbar__title" (if @showFullTitle "full-title")}} > - {{#if (has-block)}} - {{if @icon (icon @icon)}} - {{@title}} - {{yield (hash SubTitle=SubTitle)}} + {{#if @openThreadTitleModal}} + {{else}} - {{if - @icon - (icon @icon) - }}{{@title}} + + {{if @icon (icon @icon)}} + {{@title}} + + {{/if}} + {{#if (has-block)}} + {{yield (hash SubTitle=SubTitle)}} {{/if}} ; diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.gjs b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.gjs index 109c56633eb..c7aaade9afd 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat/thread/header.gjs @@ -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; + } + } +