diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb new file mode 100644 index 0000000000..fa27b825d8 --- /dev/null +++ b/plugins/chat/app/controllers/api_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Chat::Api < Chat::ChatBaseController + before_action :ensure_logged_in + before_action :ensure_can_chat + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end +end diff --git a/plugins/chat/app/controllers/chat/api/chat_tracking_controller.rb b/plugins/chat/app/controllers/chat/api/chat_tracking_controller.rb new file mode 100644 index 0000000000..3372de394a --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/chat_tracking_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::Api::ChatTrackingController < Chat::Api + def read + channel_id = params[:channel_id] + message_id = params[:message_id] + end +end diff --git a/plugins/chat/app/controllers/chat_base_controller.rb b/plugins/chat/app/controllers/chat_base_controller.rb new file mode 100644 index 0000000000..789491507a --- /dev/null +++ b/plugins/chat/app/controllers/chat_base_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Chat::ChatBaseController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_can_chat + + include Chat::WithServiceHelper + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def set_channel_and_chatable_with_access_check(chat_channel_id: nil) + params.require(:chat_channel_id) if chat_channel_id.blank? + id_or_name = chat_channel_id || params[:chat_channel_id] + @chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian) + @chatable = @chat_channel.chatable + end + + def default_actions_for_service + proc do + on_success { render(json: success_json) } + on_failure { render(json: failed_json, status: 422) } + on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } + on_failed_contract do + render( + json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages), + status: 400, + ) + end + end + end +end diff --git a/plugins/chat/app/controllers/chat_controller.rb b/plugins/chat/app/controllers/chat_controller.rb new file mode 100644 index 0000000000..afe131e897 --- /dev/null +++ b/plugins/chat/app/controllers/chat_controller.rb @@ -0,0 +1,447 @@ +# frozen_string_literal: true + +class Chat::ChatController < Chat::ChatBaseController + PAST_MESSAGE_LIMIT = 40 + FUTURE_MESSAGE_LIMIT = 40 + PAST = "past" + FUTURE = "future" + CHAT_DIRECTIONS = [PAST, FUTURE] + + # Other endpoints use set_channel_and_chatable_with_access_check, but + # these endpoints require a standalone find because they need to be + # able to get deleted channels and recover them. + before_action :find_chatable, only: %i[enable_chat disable_chat] + before_action :find_chat_message, + only: %i[delete restore lookup_message edit_message rebake message_link] + before_action :set_channel_and_chatable_with_access_check, + except: %i[ + respond + enable_chat + disable_chat + message_link + lookup_message + set_user_chat_status + dismiss_retention_reminder + flag + ] + + def respond + render + end + + def enable_chat + chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) + + guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel + + if chat_channel && chat_channel.trashed? + chat_channel.recover! + elsif chat_channel + return render_json_error I18n.t("chat.already_enabled") + else + chat_channel = @chatable.chat_channel + guardian.ensure_can_join_chat_channel!(chat_channel) + end + + success = chat_channel.save + if success && chat_channel.chatable_has_custom_fields? + @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true + @chatable.save! + end + + if success + membership = Chat::ChatChannelMembershipManager.new(channel).follow(user) + render_serialized(chat_channel, ChatChannelSerializer, membership: membership) + else + render_json_error(chat_channel) + end + + Chat::ChatChannelMembershipManager.new(channel).follow(user) + end + + def disable_chat + chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) + guardian.ensure_can_join_chat_channel!(chat_channel) + return render json: success_json if chat_channel.trashed? + chat_channel.trash!(current_user) + + success = chat_channel.save + if success + if chat_channel.chatable_has_custom_fields? + @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) + @chatable.save! + end + + render json: success_json + else + render_json_error(chat_channel) + end + end + + def create_message + raise Discourse::InvalidAccess if current_user.silenced? + + Chat::ChatMessageRateLimiter.run!(current_user) + + @user_chat_channel_membership = + Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::InvalidAccess unless @user_chat_channel_membership + + reply_to_msg_id = params[:in_reply_to_id] + if reply_to_msg_id + rm = ChatMessage.find(reply_to_msg_id) + raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id + end + + content = params[:message] + + chat_message_creator = + Chat::ChatMessageCreator.create( + chat_channel: @chat_channel, + user: current_user, + in_reply_to_id: reply_to_msg_id, + content: content, + staged_id: params[:staged_id], + upload_ids: params[:upload_ids], + ) + + return render_json_error(chat_message_creator.error) if chat_message_creator.failed? + + @user_chat_channel_membership.update!( + last_read_message_id: chat_message_creator.chat_message.id, + ) + + if @chat_channel.direct_message_channel? + # If any of the channel users is ignoring, muting, or preventing DMs from + # the current user then we shold not auto-follow the channel once again or + # publish the new channel. + user_ids_allowing_communication = + UserCommScreener.new( + acting_user: current_user, + target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), + ).allowing_actor_communication + + if user_ids_allowing_communication.any? + ChatPublisher.publish_new_channel( + @chat_channel, + @chat_channel.chatable.users.where(id: user_ids_allowing_communication), + ) + + @chat_channel + .user_chat_channel_memberships + .where(user_id: user_ids_allowing_communication) + .update_all(following: true) + end + end + + ChatPublisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + chat_message_creator.chat_message.id, + ) + render json: success_json + end + + def edit_message + chat_message_updater = + Chat::ChatMessageUpdater.update( + guardian: guardian, + chat_message: @message, + new_content: params[:new_message], + upload_ids: params[:upload_ids] || [], + ) + + return render_json_error(chat_message_updater.error) if chat_message_updater.failed? + + render json: success_json + end + + def update_user_last_read + with_service(Chat::Service::UpdateUserLastRead, channel_id: params[:chat_channel_id]) do + on_failed_policy(:ensure_message_id_recency) do + raise Discourse::InvalidParameters.new(:message_id) + end + on_failed_policy(:ensure_message_exists) { raise Discourse::NotFound } + on_model_not_found(:active_membership) { raise Discourse::NotFound } + on_model_not_found(:channel) { raise Discourse::NotFound } + end + end + + def messages + page_size = params[:page_size]&.to_i || 1000 + direction = params[:direction].to_s + message_id = params[:message_id] + if page_size > 50 || + ( + message_id.blank? ^ direction.blank? && + (direction.present? && !CHAT_DIRECTIONS.include?(direction)) + ) + raise Discourse::InvalidParameters + end + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + if message_id.present? + condition = direction == PAST ? "<" : ">" + messages = messages.where("id #{condition} ?", message_id.to_i) + end + + # NOTE: This order is reversed when we return the ChatView below if the direction + # is not FUTURE. + order = direction == FUTURE ? "ASC" : "DESC" + messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a + + can_load_more_past = nil + can_load_more_future = nil + + if direction == FUTURE + can_load_more_future = messages.size == page_size + elsif direction == PAST + can_load_more_past = messages.size == page_size + else + # When direction is blank, we'll return the latest messages. + can_load_more_future = false + can_load_more_past = messages.size == page_size + end + + chat_view = + ChatView.new( + chat_channel: @chat_channel, + chat_messages: direction == FUTURE ? messages : messages.reverse, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, ChatViewSerializer, root: false) + end + + def react + params.require(%i[message_id emoji react_action]) + guardian.ensure_can_react! + + Chat::ChatMessageReactor.new(current_user, @chat_channel).react!( + message_id: params[:message_id], + react_action: params[:react_action].to_sym, + emoji: params[:emoji], + ) + + render json: success_json + end + + def delete + guardian.ensure_can_delete_chat!(@message, @chatable) + + ChatMessageDestroyer.new.trash_message(@message, current_user) + + head :ok + end + + def restore + chat_channel = @message.chat_channel + guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) + updated = @message.recover! + if updated + ChatPublisher.publish_restore!(chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + end + + def rebake + guardian.ensure_can_rebake_chat_message!(@message) + @message.rebake!(invalidate_oneboxes: true) + render json: success_json + end + + def message_link + raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? + raise Discourse::NotFound if @message.chat_channel.blank? + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + render json: + success_json.merge( + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(current_user), + ) + end + + def lookup_message + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + past_messages = + messages + .where("created_at < ?", @message.created_at) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + + future_messages = + messages + .where("created_at > ?", @message.created_at) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + + can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT + messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) + chat_view = + ChatView.new( + chat_channel: @chat_channel, + chat_messages: messages, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, ChatViewSerializer, root: false) + end + + def set_user_chat_status + params.require(:chat_enabled) + + current_user.user_option.update(chat_enabled: params[:chat_enabled]) + render json: { chat_enabled: current_user.user_option.chat_enabled } + end + + def invite_users + params.require(:user_ids) + + users = + User + .includes(:groups) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .not_suspended + .where(id: params[:user_ids]) + users.each do |user| + guardian = Guardian.new(user) + if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + data = { + message: "chat.invitation_notification", + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(user), + chat_channel_slug: @chat_channel.slug, + invited_by_username: current_user.username, + } + data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] + user.notifications.create( + notification_type: Notification.types[:chat_invitation], + high_priority: true, + data: data.to_json, + ) + end + end + + render json: success_json + end + + def dismiss_retention_reminder + params.require(:chatable_type) + guardian.ensure_can_chat! + unless ChatChannel.chatable_types.include?(params[:chatable_type]) + raise Discourse::InvalidParameters + end + + field = + ( + if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type]) + :dismissed_channel_retention_reminder + else + :dismissed_dm_retention_reminder + end + ) + current_user.user_option.update(field => true) + render json: success_json + end + + def quote_messages + params.require(:message_ids) + + message_ids = params[:message_ids].map(&:to_i) + markdown = + ChatTranscriptService.new( + @chat_channel, + current_user, + messages_or_ids: message_ids, + ).generate_markdown + render json: success_json.merge(markdown: markdown) + end + + def flag + RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! + + permitted_params = + params.permit( + %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], + ) + + chat_message = + ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) + + flag_type_id = permitted_params[:flag_type_id].to_i + + if !ReviewableScore.types.values.include?(flag_type_id) + raise Discourse::InvalidParameters.new(:flag_type_id) + end + + set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) + + result = + Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) + + if result[:success] + render json: success_json + else + render_json_error(result[:errors]) + end + end + + def set_draft + if params[:data].present? + ChatDraft.find_or_initialize_by( + user: current_user, + chat_channel_id: @chat_channel.id, + ).update!(data: params[:data]) + else + ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all + end + + render json: success_json + end + + private + + def preloaded_chat_message_query + query = + ChatMessage + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(user: :primary_group) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + + query = query.includes(user: :user_status) if SiteSetting.enable_user_status + + query + end + + def find_chatable + @chatable = Category.find_by(id: params[:chatable_id]) + guardian.ensure_can_moderate_chat!(@chatable) + end + + def find_chat_message + @message = preloaded_chat_message_query.with_deleted + @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id] + @message = @message.find_by(id: params[:message_id]) + raise Discourse::NotFound unless @message + end +end diff --git a/plugins/chat/app/helpers/chat/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb index c8e820cc2c..e9c3191cf8 100644 --- a/plugins/chat/app/helpers/chat/with_service_helper.rb +++ b/plugins/chat/app/helpers/chat/with_service_helper.rb @@ -5,6 +5,9 @@ module Chat @_result end + # @param service [Class] A class including {Chat::Service::Base} + # @param dependencies [kwargs] Any additional params to load into the service context, + # in addition to controller @params. def with_service(service, default_actions: true, **dependencies, &block) object = self merged_block = diff --git a/plugins/chat/app/services/update_user_last_read.rb b/plugins/chat/app/services/update_user_last_read.rb new file mode 100644 index 0000000000..914b484602 --- /dev/null +++ b/plugins/chat/app/services/update_user_last_read.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Chat + module Service + # Service responsible for updating the last read message id of a membership. + # + # @example + # Chat::Service::UpdateUserLastRead.call(channel_id: 2, message_id: 3, guardian: guardian) + # + class UpdateUserLastRead + include Base + + # @!method call(user_id:, channel_id:, message_id:, guardian:) + # @param [Integer] channel_id + # @param [Integer] message_id + # @param [Guardian] guardian + # @return [Chat::Service::Base::Context] + + contract + model :channel + model :active_membership + policy :invalid_access + policy :ensure_message_exists + policy :ensure_message_id_recency + step :update_last_read_message_id + step :mark_associated_mentions_as_read + step :publish_new_last_read_to_clients + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :channel_id, :integer + + validates :message_id, :channel_id, presence: true + end + + private + + def fetch_channel(contract:, **) + ChatChannel.find_by(id: contract.channel_id) + end + + def fetch_active_membership(guardian:, channel:, **) + Chat::ChatChannelMembershipManager.new(channel).find_for_user( + guardian.user, + following: true, + ) + end + + def invalid_access(guardian:, active_membership:, **) + guardian.can_join_chat_channel?(active_membership.chat_channel) + end + + def ensure_message_exists(channel:, contract:, **) + ChatMessage.with_deleted.exists?(chat_channel_id: channel.id, id: contract.message_id) + end + + def ensure_message_id_recency(contract:, active_membership:, **) + !active_membership.last_read_message_id || + contract.message_id >= active_membership.last_read_message_id + end + + def update_last_read_message_id(contract:, active_membership:, **) + active_membership.update!(last_read_message_id: contract.message_id) + end + + def mark_associated_mentions_as_read(active_membership:, contract:, **) + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: active_membership.user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", contract.message_id) + .where("chat_messages.chat_channel_id = ?", active_membership.chat_channel.id) + .update_all(read: true) + end + + def publish_new_last_read_to_clients(guardian:, channel:, contract:, **) + ChatPublisher.publish_user_tracking_state(guardian.user, channel.id, contract.message_id) + end + end + end +end diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js index ae70c3e454..34a00c5ccf 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -17,6 +17,9 @@ export default { const router = container.lookup("service:router"); const appEvents = container.lookup("service:app-events"); const chatStateManager = container.lookup("service:chat-state-manager"); + const chatChannelsManager = container.lookup( + "service:chat-channels-manager" + ); const openChannelSelector = (e) => { e.preventDefault(); e.stopPropagation(); @@ -92,6 +95,12 @@ export default { appEvents.trigger("chat:toggle-close", event); }; + const markAllChannelsRead = (event) => { + event.preventDefault(); + event.stopPropagation(); + chatChannelsManager.markAllChannelsRead(); + }; + withPluginApi("0.12.1", (api) => { api.addKeyboardShortcut(`${KEY_MODIFIER}+k`, openChannelSelector, { global: true, @@ -201,6 +210,21 @@ export default { }, }, }); + api.addKeyboardShortcut( + `shift+esc`, + (event) => markAllChannelsRead(event), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.mark_all_channels_read", + definition: { + keys1: ["shift", "esc"], + keysDelimiter: "plus", + }, + }, + } + ); }); }, }; diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 44ad75dc5c..d1ca13f3d9 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -282,6 +282,13 @@ export default class ChatApi extends Service { ); } + updateCurrentUserTracking({ channelId, messageId } = {}) { + return this.#putRequest(`/tracking/read/me`, { + channel_id: channelId, + message_id: messageId, + }); + } + get #basePath() { return "/chat/api"; } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js index 0905f46802..66463af524 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -1,4 +1,5 @@ import Service, { inject as service } from "@ember/service"; +import { debounce } from "discourse-common/utils/decorators"; import Promise from "rsvp"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import { tracked } from "@glimmer/tracking"; @@ -82,6 +83,16 @@ export default class ChatChannelsManager extends Service { }); } + @debounce(300) + async markAllChannelsRead() { + return this.chatApi.updateCurrentUserTracking().then(() => { + this.channels.forEach((channel) => { + channel.currentUserMembership.unread_count = 0; + channel.currentUserMembership.unread_mentions = 0; + }); + }); + } + get unreadCount() { let count = 0; this.publicMessageChannels.forEach((channel) => { diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 5925fe1e6f..b9b5f112aa 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -598,6 +598,7 @@ en: composer_code: "%{shortcut} Code (composer only)" drawer_open: "%{shortcut} Open chat drawer" drawer_close: "%{shortcut} Close chat drawer" + mark_all_channels_read: "%{shortcut} Mark all channels read" topic_statuses: chat: help: "Chat is enabled for this topic" diff --git a/plugins/chat/lib/service_runner.rb b/plugins/chat/lib/service_runner.rb index b82ff5c9dc..ad3eeafdec 100644 --- a/plugins/chat/lib/service_runner.rb +++ b/plugins/chat/lib/service_runner.rb @@ -19,6 +19,8 @@ # * +on_model_not_found(name)+: will execute the provided block if the service # fails and its model is not present # +# Default actions for each of these are defined in [Chat::BaseController#default_actions_for_service] +# # @example In a controller # def create # with_service MyService do diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 1f2f5b81d9..b83ad11bb7 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -381,25 +381,6 @@ after_initialize do end end - Discourse::Application.routes.append do - mount ::Chat::Engine, at: "/chat" - - get "/admin/plugins/chat" => "chat/admin/incoming_webhooks#index", - :constraints => StaffConstraint.new - post "/admin/plugins/chat/hooks" => "chat/admin/incoming_webhooks#create", - :constraints => StaffConstraint.new - put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin/incoming_webhooks#update", - :constraints => StaffConstraint.new - delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin/incoming_webhooks#destroy", - :constraints => StaffConstraint.new - get "u/:username/preferences/chat" => "users#preferences", - :constraints => { - username: RouteFormat.username, - } - end - if defined?(DiscourseAutomation) add_automation_scriptable("send_chat_message") do field :chat_channel_id, component: :text, required: true