diff --git a/.gitignore b/.gitignore index 11f40d38b65..25f9373afc7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ !/plugins/discourse-narrative-bot !/plugins/discourse-presence !/plugins/lazy-yt/ +!/plugins/chat/ !/plugins/poll/ !/plugins/styleguide /plugins/*/auto_generated/ diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 8add4161d91..b65d28df8a2 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -232,45 +232,52 @@ end # # Table name: user_options # -# user_id :integer not null, primary key -# mailing_list_mode :boolean default(FALSE), not null -# email_digests :boolean -# external_links_in_new_tab :boolean default(FALSE), not null -# enable_quoting :boolean default(TRUE), not null -# dynamic_favicon :boolean default(FALSE), not null -# automatically_unpin_topics :boolean default(TRUE), not null -# digest_after_minutes :integer -# auto_track_topics_after_msecs :integer -# new_topic_duration_minutes :integer -# last_redirected_to_top_at :datetime -# email_previous_replies :integer default(2), not null -# email_in_reply_to :boolean default(TRUE), not null -# like_notification_frequency :integer default(1), not null -# mailing_list_mode_frequency :integer default(1), not null -# include_tl0_in_digests :boolean default(FALSE) -# notification_level_when_replying :integer -# theme_key_seq :integer default(0), not null -# allow_private_messages :boolean default(TRUE), not null -# homepage_id :integer -# theme_ids :integer default([]), not null, is an Array -# hide_profile_and_presence :boolean default(FALSE), not null -# text_size_key :integer default(0), not null -# text_size_seq :integer default(0), not null -# email_level :integer default(1), not null -# email_messages_level :integer default(0), not null -# title_count_mode_key :integer default(0), not null -# enable_defer :boolean default(FALSE), not null -# timezone :string -# enable_allowed_pm_users :boolean default(FALSE), not null -# dark_scheme_id :integer -# skip_new_user_tips :boolean default(FALSE), not null -# color_scheme_id :integer -# default_calendar :integer default("none_selected"), not null -# oldest_search_log_date :datetime -# bookmark_auto_delete_preference :integer default(3), not null -# enable_experimental_sidebar :boolean default(FALSE) -# seen_popups :integer is an Array -# sidebar_list_destination :integer default("none_selected"), not null +# user_id :integer not null, primary key +# mailing_list_mode :boolean default(FALSE), not null +# email_digests :boolean +# external_links_in_new_tab :boolean default(FALSE), not null +# enable_quoting :boolean default(TRUE), not null +# dynamic_favicon :boolean default(FALSE), not null +# automatically_unpin_topics :boolean default(TRUE), not null +# digest_after_minutes :integer +# auto_track_topics_after_msecs :integer +# new_topic_duration_minutes :integer +# last_redirected_to_top_at :datetime +# email_previous_replies :integer default(2), not null +# email_in_reply_to :boolean default(TRUE), not null +# like_notification_frequency :integer default(1), not null +# mailing_list_mode_frequency :integer default(1), not null +# include_tl0_in_digests :boolean default(FALSE) +# notification_level_when_replying :integer +# theme_key_seq :integer default(0), not null +# allow_private_messages :boolean default(TRUE), not null +# homepage_id :integer +# theme_ids :integer default([]), not null, is an Array +# hide_profile_and_presence :boolean default(FALSE), not null +# text_size_key :integer default(0), not null +# text_size_seq :integer default(0), not null +# email_level :integer default(1), not null +# email_messages_level :integer default(0), not null +# title_count_mode_key :integer default(0), not null +# enable_defer :boolean default(FALSE), not null +# timezone :string +# enable_allowed_pm_users :boolean default(FALSE), not null +# dark_scheme_id :integer +# skip_new_user_tips :boolean default(FALSE), not null +# color_scheme_id :integer +# default_calendar :integer default("none_selected"), not null +# chat_enabled :boolean default(TRUE), not null +# only_chat_push_notifications :boolean +# oldest_search_log_date :datetime +# chat_sound :string +# dismissed_channel_retention_reminder :boolean +# dismissed_dm_retention_reminder :boolean +# bookmark_auto_delete_preference :integer default(3), not null +# ignore_channel_wide_mention :boolean +# chat_email_frequency :integer default(1), not null +# enable_experimental_sidebar :boolean default(FALSE) +# seen_popups :integer is an Array +# sidebar_list_destination :integer default("none_selected"), not null # # Indexes # diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index 703c459f031..b4109e754ab 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -26,7 +26,6 @@ class Plugin::Metadata "discourse-categories-suppressed", "discourse-category-experts", "discourse-characters-required", - "discourse-chat", "discourse-chat-integration", "discourse-checklist", "discourse-code-review", @@ -91,6 +90,7 @@ class Plugin::Metadata "discourse-yearly-review", "discourse-zendesk-plugin", "docker_manager", + "chat", "lazy-yt", "poll", "styleguide", diff --git a/plugins/chat/README.md b/plugins/chat/README.md new file mode 100644 index 00000000000..fc0b204240b --- /dev/null +++ b/plugins/chat/README.md @@ -0,0 +1,54 @@ +:warning: This plugin is still in active development and may change frequently + +## Documentation + +The Discourse Chat plugin adds chat functionality to your Discourse so it can natively support both long-form and short-form communication needs of your online community. + +For documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881) + +## Plugin API + +### registerChatComposerButton + +#### Usage + +```javascript +api.registerChatComposerButton({ id: "foo", ... }); +``` + +#### Options + +Every option accepts a `value` or a `function`, when passing a function `this` will be the `chat-composer` component instance. Example of an option using a function: + +```javascript +api.registerChatComposerButton({ + id: "foo", + displayed() { + return this.site.mobileView && this.canAttachUploads; + }, +}); +``` + +##### Required + +- `id` unique, used to identify your button, eg: "gifs" +- `action` callback when the button is pressed, can be an action name or an anonymous function, eg: "onFooClicked" or `() => { console.log("clicked") }` + +A button requires at least an icon or a label: + +- `icon`, eg: "times" +- `label`, text displayed on the button, a translatable key, eg: "foo.bar" +- `translatedLabel`, text displayed on the button, a string, eg: "Add gifs" + +##### Optional + +- `position`, can be "inline" or "dropdown", defaults to "inline" +- `title`, title attribute of the button, a translatable key, eg: "foo.bar" +- `translatedTitle`, title attribute of the button, a string, eg: "Add gifs" +- `ariaLabel`, aria-label attribute of the button, a translatable key, eg: "foo.bar" +- `translatedAriaLabel`, aria-label attribute of the button, a string, eg: "Add gifs" +- `classNames`, additional names to add to the button’s class attribute, eg: ["foo", "bar"] +- `displayed`, hide/or show the button, expects a boolean +- `disabled`, sets the disabled attribute on the button, expects a boolean +- `priority`, an integer defining the order of the buttons, higher comes first, eg: `700` +- `dependentKeys`, list of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]` diff --git a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb new file mode 100644 index 00000000000..24bcd25abda --- /dev/null +++ b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Chat::AdminIncomingChatWebhooksController < Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def index + render_serialized( + { + chat_channels: ChatChannel.public_channels, + incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all, + }, + AdminChatIndexSerializer, + root: false, + ) + end + + def create + params.require(%i[name chat_channel_id]) + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel) + if webhook.save + render_serialized(webhook, IncomingChatWebhookSerializer, root: false) + else + render_json_error(webhook) + end + end + + def update + params.require(%i[incoming_chat_webhook_id name chat_channel_id]) + + webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) + raise Discourse::NotFound unless webhook + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + if webhook.update( + name: params[:name], + description: params[:description], + emoji: params[:emoji], + username: params[:username], + chat_channel: chat_channel, + ) + render json: success_json + else + render_json_error(webhook) + end + end + + def destroy + params.require(:incoming_chat_webhook_id) + + webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) + webhook.destroy if webhook + render json: success_json + end +end diff --git a/plugins/chat/app/controllers/api/category_chatables_controller.rb b/plugins/chat/app/controllers/api/category_chatables_controller.rb new file mode 100644 index 00000000000..50fe11edc7c --- /dev/null +++ b/plugins/chat/app/controllers/api/category_chatables_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Chat::Api::CategoryChatablesController < ApplicationController + def permissions + category = Category.find(params[:id]) + + if category.read_restricted? + permissions = + Group + .joins(:category_groups) + .where(category_groups: { category_id: category.id }) + .joins("LEFT OUTER JOIN group_users ON groups.id = group_users.group_id") + .group("groups.id", "groups.name") + .pluck("groups.name", "COUNT(group_users.user_id)") + + group_names = permissions.map { |p| "@#{p[0]}" } + members_count = permissions.sum { |p| p[1].to_i } + + permissions_result = { + allowed_groups: group_names, + members_count: members_count, + private: true, + } + else + everyone_group = Group.find(Group::AUTO_GROUPS[:everyone]) + + permissions_result = { allowed_groups: ["@#{everyone_group.name}"], private: false } + end + + render json: permissions_result + end +end diff --git a/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb b/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb new file mode 100644 index 00000000000..727811c9ca6 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channel_memberships_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Chat::Api::ChatChannelMembershipsController < Chat::Api::ChatChannelsController + def index + channel = find_chat_channel + + offset = (params[:offset] || 0).to_i + limit = (params[:limit] || 50).to_i.clamp(1, 50) + + memberships = + ChatChannelMembershipsQuery.call( + channel, + offset: offset, + limit: limit, + username: params[:username], + ) + + render_serialized(memberships, UserChatChannelMembershipSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb b/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb new file mode 100644 index 00000000000..57c0055424d --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channel_notifications_settings_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] + +class Chat::Api::ChatChannelNotificationsSettingsController < Chat::Api::ChatChannelsController + def update + settings_params = params.permit(MEMBERSHIP_EDITABLE_PARAMS) + membership = find_membership + membership.update!(settings_params.to_h) + render_serialized(membership, UserChatChannelMembershipSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/chat_channels_controller.rb b/plugins/chat/app/controllers/api/chat_channels_controller.rb new file mode 100644 index 00000000000..b073936cf48 --- /dev/null +++ b/plugins/chat/app/controllers/api/chat_channels_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +CHAT_CHANNEL_EDITABLE_PARAMS = %i[name description] +CATEGORY_CHAT_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users] + +class Chat::Api::ChatChannelsController < Chat::Api + def index + options = { status: params[:status] ? ChatChannel.statuses[params[:status]] : nil }.merge( + params.permit(:filter, :limit, :offset), + ).symbolize_keys! + + memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options) + + serialized_channels = + channels.map do |channel| + ChatChannelSerializer.new( + channel, + scope: Guardian.new(current_user), + membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, + ) + end + render json: serialized_channels, root: false + end + + def update + guardian.ensure_can_edit_chat_channel! + + chat_channel = find_chat_channel + + if chat_channel.direct_message_channel? + raise Discourse::InvalidParameters.new( + I18n.t("chat.errors.cant_update_direct_message_channel"), + ) + end + + params_to_edit = editable_params(params, chat_channel) + params_to_edit.each { |k, v| params_to_edit[k] = nil if params_to_edit[k].blank? } + + if ActiveRecord::Type::Boolean.new.deserialize(params_to_edit[:auto_join_users]) + auto_join_limiter(chat_channel).performed! + end + + chat_channel.update!(params_to_edit) + + ChatPublisher.publish_chat_channel_edit(chat_channel, current_user) + + if chat_channel.category_channel? && chat_channel.auto_join_users + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + end + + render_serialized( + chat_channel, + ChatChannelSerializer, + root: false, + membership: chat_channel.membership_for(current_user), + ) + end + + private + + def find_chat_channel + chat_channel = ChatChannel.find(params.require(:chat_channel_id)) + guardian.ensure_can_see_chat_channel!(chat_channel) + chat_channel + end + + def find_membership + chat_channel = find_chat_channel + membership = Chat::ChatChannelMembershipManager.new(chat_channel).find_for_user(current_user) + raise Discourse::NotFound if membership.blank? + membership + end + + def auto_join_limiter(chat_channel) + RateLimiter.new( + current_user, + "auto_join_users_channel_#{chat_channel.id}", + 1, + 3.minutes, + apply_limit_to_staff: true, + ) + end + + def editable_params(params, chat_channel) + permitted_params = CHAT_CHANNEL_EDITABLE_PARAMS + + permitted_params += CATEGORY_CHAT_CHANNEL_EDITABLE_PARAMS if chat_channel.category_channel? + + params.permit(*permitted_params) + end +end diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb new file mode 100644 index 00000000000..eaf3db9be5c --- /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!(current_user) + 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 00000000000..14cc69f2710 --- /dev/null +++ b/plugins/chat/app/controllers/chat_base_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Chat::ChatBaseController < ::ApplicationController + 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!(current_user) + 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 +end diff --git a/plugins/chat/app/controllers/chat_channels_controller.rb b/plugins/chat/app/controllers/chat_channels_controller.rb new file mode 100644 index 00000000000..700ad43d5c9 --- /dev/null +++ b/plugins/chat/app/controllers/chat_channels_controller.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +class Chat::ChatChannelsController < Chat::ChatBaseController + before_action :set_channel_and_chatable_with_access_check, except: %i[index create search] + + def index + structured = Chat::ChatChannelFetcher.structured(guardian) + render_serialized(structured, ChatChannelIndexSerializer, root: false) + end + + def show + render_serialized( + @chat_channel, + ChatChannelSerializer, + membership: @chat_channel.membership_for(current_user), + root: false, + ) + end + + def follow + membership = @chat_channel.add(current_user) + + render_serialized(@chat_channel, ChatChannelSerializer, membership: membership, root: false) + end + + def unfollow + membership = @chat_channel.remove(current_user) + + render_serialized(@chat_channel, ChatChannelSerializer, membership: membership, root: false) + end + + def create + params.require(%i[id name]) + guardian.ensure_can_create_chat_channel! + if params[:name].length > SiteSetting.max_topic_title_length + raise Discourse::InvalidParameters.new(:name) + end + + exists = + ChatChannel.exists?(chatable_type: "Category", chatable_id: params[:id], name: params[:name]) + if exists + raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category")) + end + + chatable = Category.find_by(id: params[:id]) + raise Discourse::NotFound unless chatable + + auto_join_users = ActiveRecord::Type::Boolean.new.deserialize(params[:auto_join_users]) || false + + chat_channel = + chatable.create_chat_channel!( + name: params[:name], + description: params[:description], + user_count: 1, + auto_join_users: auto_join_users, + ) + chat_channel.user_chat_channel_memberships.create!(user: current_user, following: true) + + if chat_channel.auto_join_users + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + end + + render_serialized( + chat_channel, + ChatChannelSerializer, + membership: chat_channel.membership_for(current_user), + ) + end + + def edit + guardian.ensure_can_edit_chat_channel! + if (params[:name]&.length || 0) > SiteSetting.max_topic_title_length + raise Discourse::InvalidParameters.new(:name) + end + + chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound unless chat_channel + + chat_channel.name = params[:name] if params[:name] + chat_channel.description = params[:description] if params[:description] + chat_channel.save! + + ChatPublisher.publish_chat_channel_edit(chat_channel, current_user) + render_serialized( + chat_channel, + ChatChannelSerializer, + membership: chat_channel.membership_for(current_user), + ) + end + + def search + params.require(:filter) + filter = params[:filter]&.downcase + memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + public_channels = + Chat::ChatChannelFetcher.secured_public_channels( + guardian, + memberships, + filter: filter, + status: :open, + ) + + users = User.joins(:user_option).where.not(id: current_user.id) + if !Chat.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) + users = + users + .joins(:groups) + .where(groups: { id: Chat.allowed_group_ids }) + .or(users.joins(:groups).staff) + end + + users = users.where(user_option: { chat_enabled: true }) + like_filter = "%#{filter}%" + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + users = users.where("users.username_lower ILIKE ?", like_filter) + else + users = + users.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + like_filter, + like_filter, + ) + end + + users = users.limit(25).uniq + + direct_message_channels = + ( + if users.count > 0 + ChatChannel + .includes(chatable: :users) + .joins(direct_message_channel: :direct_message_users) + .group(1) + .having( + "ARRAY[?] <@ ARRAY_AGG(user_id) AND ARRAY[?] && ARRAY_AGG(user_id)", + [current_user.id], + users.map(&:id), + ) + else + [] + end + ) + + user_ids_with_channel = [] + direct_message_channels.each do |dm_channel| + user_ids = dm_channel.chatable.users.map(&:id) + user_ids_with_channel.concat(user_ids) if user_ids.count < 3 + end + + users_without_channel = users.filter { |u| !user_ids_with_channel.include?(u.id) } + + if current_user.username.downcase.start_with?(filter) + # We filtered out the current user for the query earlier, but check to see + # if they should be included, and add. + users_without_channel << current_user + end + + render_serialized( + { + public_channels: public_channels, + direct_message_channels: direct_message_channels, + users: users_without_channel, + memberships: memberships, + }, + ChatChannelSearchSerializer, + root: false, + ) + end + + def archive + params.require(:type) + + if params[:type] == "newTopic" ? params[:title].blank? : params[:topic_id].blank? + raise Discourse::InvalidParameters + end + + if !guardian.can_change_channel_status?(@chat_channel, :read_only) + raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived")) + end + + Chat::ChatChannelArchiveService.begin_archive_process( + chat_channel: @chat_channel, + acting_user: current_user, + topic_params: { + topic_id: params[:topic_id], + topic_title: params[:title], + category_id: params[:category_id], + tags: params[:tags], + }, + ) + + render json: success_json + end + + def retry_archive + guardian.ensure_can_change_channel_status!(@chat_channel, :archived) + + archive = @chat_channel.chat_channel_archive + raise Discourse::NotFound if archive.blank? + raise Discourse::InvalidAccess if !archive.failed? + + Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: @chat_channel) + + render json: success_json + end + + def change_status + params.require(:status) + + # we only want to use this endpoint for open/closed status changes, + # the others are more "special" and are handled by the archive endpoint + if !ChatChannel.statuses.keys.include?(params[:status]) || params[:status] == "read_only" || + params[:status] == "archive" + raise Discourse::InvalidParameters + end + + guardian.ensure_can_change_channel_status!(@chat_channel, params[:status].to_sym) + @chat_channel.public_send("#{params[:status]}!", current_user) + + render json: success_json + end + + def destroy + params.require(:channel_name_confirmation) + + guardian.ensure_can_delete_chat_channel! + + if @chat_channel.title(current_user).downcase != params[:channel_name_confirmation].downcase + raise Discourse::InvalidParameters.new(:channel_name_confirmation) + end + + begin + ChatChannel.transaction do + @chat_channel.trash!(current_user) + StaffActionLogger.new(current_user).log_custom( + "chat_channel_delete", + { + chat_channel_id: @chat_channel.id, + chat_channel_name: @chat_channel.title(current_user), + }, + ) + end + rescue ActiveRecord::Rollback + return render_json_error(I18n.t("chat.errors.delete_channel_failed")) + end + + Jobs.enqueue(:chat_channel_delete, { chat_channel_id: @chat_channel.id }) + render json: success_json + 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 00000000000..287bcd8f359 --- /dev/null +++ b/plugins/chat/app/controllers/chat_controller.rb @@ -0,0 +1,500 @@ +# frozen_string_literal: true + +class Chat::ChatController < Chat::ChatBaseController + PAST_MESSAGE_LIMIT = 20 + 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_see_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_see_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_see_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? + + @chat_channel.touch(:last_message_sent_at) + @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? + @chat_channel + .user_chat_channel_memberships + .where(user_id: user_ids_allowing_communication) + .update_all(following: true) + ChatPublisher.publish_new_channel( + @chat_channel, + @chat_channel.chatable.users.where(id: user_ids_allowing_communication), + ) + 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 + guardian.ensure_can_edit_chat!(@message) + chat_message_updater = + Chat::ChatMessageUpdater.update( + 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 + membership = + Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::NotFound if membership.nil? + + if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id + raise Discourse::InvalidParameters.new(:message_id) + end + + unless ChatMessage.with_deleted.exists?( + chat_channel_id: @chat_channel.id, + id: params[:message_id], + ) + raise Discourse::NotFound + end + + membership.update!(last_read_message_id: params[:message_id]) + + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: current_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 <= ?", params[:message_id].to_i) + .where("chat_messages.chat_channel_id = ?", @chat_channel.id) + .update_all(read: true) + + ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id]) + + render json: success_json + 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) + + updated = @message.trash!(current_user) + if updated + ChatPublisher.publish_delete!(@chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + 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 + return render_404 if @message.blank? || @message.deleted_at.present? + return render_404 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?(user) && guardian.can_see_chat_channel?(@chat_channel) + data = { + message: "chat.invitation_notification", + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(user), + 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!(current_user) + 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 move_messages_to_channel + params.require(:message_ids) + params.require(:destination_channel_id) + + raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(@chat_channel) + destination_channel = + Chat::ChatChannelFetcher.find_with_access_check(params[:destination_channel_id], guardian) + + begin + message_ids = params[:message_ids].map(&:to_i) + moved_messages = + Chat::MessageMover.new( + acting_user: current_user, + source_channel: @chat_channel, + message_ids: message_ids, + ).move_to_channel(destination_channel) + rescue Chat::MessageMover::NoMessagesFound, Chat::MessageMover::InvalidChannel => err + return render_json_error(err.message) + end + + render json: + success_json.merge( + destination_channel_id: destination_channel.id, + destination_channel_title: destination_channel.title(current_user), + first_moved_message_id: moved_messages.first.id, + ) + 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) + .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/controllers/direct_messages_controller.rb b/plugins/chat/app/controllers/direct_messages_controller.rb new file mode 100644 index 00000000000..920c440ce00 --- /dev/null +++ b/plugins/chat/app/controllers/direct_messages_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Chat::DirectMessagesController < Chat::ChatBaseController + # NOTE: For V1 of chat channel archiving and deleting we are not doing + # anything for DM channels, their behaviour will stay as is. + def create + guardian.ensure_can_chat!(current_user) + users = users_from_usernames(current_user, params) + + begin + chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) + render_serialized( + chat_channel, + ChatChannelSerializer, + root: "chat_channel", + membership: chat_channel.membership_for(current_user), + ) + rescue Chat::DirectMessageChannelCreator::NotAllowed => err + render_json_error(err.message) + end + end + + def index + guardian.ensure_can_chat!(current_user) + users = users_from_usernames(current_user, params) + + direct_message_channel = DirectMessageChannel.for_user_ids(users.map(&:id).uniq) + if direct_message_channel + chat_channel = + ChatChannel.find_by( + chatable_id: direct_message_channel.id, + chatable_type: "DirectMessageChannel", + ) + render_serialized( + chat_channel, + ChatChannelSerializer, + root: "chat_channel", + membership: chat_channel.membership_for(current_user), + ) + else + render body: nil, status: 404 + end + end + + private + + def users_from_usernames(current_user, params) + params.require(:usernames) + + usernames = + (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) + + users = [current_user] + other_usernames = usernames - [current_user.username] + users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? + users + end +end diff --git a/plugins/chat/app/controllers/emojis_controller.rb b/plugins/chat/app/controllers/emojis_controller.rb new file mode 100644 index 00000000000..8d895e2bd70 --- /dev/null +++ b/plugins/chat/app/controllers/emojis_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::EmojisController < Chat::ChatBaseController + def index + emojis = Emoji.all.group_by(&:group) + render json: MultiJson.dump(emojis) + end +end diff --git a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb new file mode 100644 index 00000000000..1cf4963621e --- /dev/null +++ b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class Chat::IncomingChatWebhooksController < ApplicationController + WEBHOOK_MAX_MESSAGE_LENGTH = 2000 + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 + + skip_before_action :verify_authenticity_token, :redirect_to_login_if_required + + before_action :validate_payload + + def create_message + debug_payload + + process_webhook_payload(text: params[:text], key: params[:key]) + end + + # See https://api.slack.com/reference/messaging/payload for the + # slack message payload format. For now we only support the + # text param, which we preprocess lightly to remove the slack-isms + # in the formatting. + def create_message_slack_compatible + debug_payload + + # See note in validate_payload on why this is needed + attachments = + if params[:payload].present? + payload = params[:payload] + if String === payload + payload = JSON.parse(payload) + payload.deep_symbolize_keys! + end + payload[:attachments] + else + params[:attachments] + end + + if params[:text].present? + text = Chat::SlackCompatibility.process_text(params[:text]) + else + text = Chat::SlackCompatibility.process_legacy_attachments(attachments) + end + + process_webhook_payload(text: text, key: params[:key]) + rescue JSON::ParserError + raise Discourse::InvalidParameters + end + + private + + def process_webhook_payload(text:, key:) + validate_message_length(text) + webhook = find_and_rate_limit_webhook(key) + + chat_message_creator = + Chat::ChatMessageCreator.create( + chat_channel: webhook.chat_channel, + user: Discourse.system_user, + content: text, + incoming_chat_webhook: webhook, + ) + if chat_message_creator.failed? + render_json_error(chat_message_creator.error) + else + render json: success_json + end + end + + def find_and_rate_limit_webhook(key) + webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key) + raise Discourse::NotFound unless webhook + + # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. + RateLimiter.new( + nil, + "incoming_chat_webhook_#{webhook.id}", + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, + 1.minute, + ).performed! + webhook + end + + def validate_message_length(message) + return if message.length <= WEBHOOK_MAX_MESSAGE_LENGTH + raise Discourse::InvalidParameters.new( + "Body cannot be over #{WEBHOOK_MAX_MESSAGE_LENGTH} characters", + ) + end + + def validate_payload + params.require([:key]) + + # TODO (martin) It is not clear whether the :payload key is actually + # present in the webhooks sent from OpsGenie, so once it is confirmed + # in production what we are actually getting then we can remove this. + if !params[:text] && !params[:payload] && !params[:attachments] + raise Discourse::InvalidParameters + end + end + + def debug_payload + return if !SiteSetting.chat_debug_webhook_payloads + Rails.logger.warn( + "Debugging chat webhook payload: " + + JSON.dump( + { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, + ), + ) + end +end diff --git a/plugins/chat/app/core_ext/plugin_instance.rb b/plugins/chat/app/core_ext/plugin_instance.rb new file mode 100644 index 00000000000..9e38199f2ed --- /dev/null +++ b/plugins/chat/app/core_ext/plugin_instance.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) + +class Plugin::Instance + def chat + ChatPluginApiExtensions + end + + module ChatPluginApiExtensions + def self.enable_markdown_feature(name) + DiscoursePluginRegistry.chat_markdown_features << name + end + end +end diff --git a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb new file mode 100644 index 00000000000..a4a11270de7 --- /dev/null +++ b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Jobs + class AutoJoinChannelBatch < ::Jobs::Base + def execute(args) + return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? + start_user_id = args[:starts_at].to_i + end_user_id = args[:ends_at].to_i + + return "End is higher than start" if end_user_id < start_user_id + + channel = + ChatChannel.find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel + + category = channel.chatable + return if !category + + query_args = { + chat_channel_id: channel.id, + start: start_user_id, + end: end_user_id, + suspended_until: Time.zone.now, + last_seen_at: 3.months.ago, + channel_category: channel.chatable_id, + mode: UserChatChannelMembership.join_modes[:automatic], + } + + new_member_ids = DB.query_single(create_memberships_query(category), query_args) + + # Only do this if we are running auto-join for a single user, if we + # are doing it for many then we should do it after all batches are + # complete for the channel in Jobs::AutoManageChannelMemberships + if start_user_id == end_user_id + Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count + end + + ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) + end + + private + + def create_memberships_query(category) + query = <<~SQL + INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) + SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode + FROM users + INNER JOIN user_options uo ON uo.user_id = users.id + LEFT OUTER JOIN user_chat_channel_memberships uccm ON + uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id + SQL + + query += <<~SQL if category.read_restricted? + INNER JOIN group_users gu ON gu.user_id = users.id + LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id + SQL + + query += <<~SQL + WHERE (users.id >= :start AND users.id <= :end) AND + users.staged IS FALSE AND users.active AND + NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND + (suspended_till IS NULL OR suspended_till <= :suspended_until) AND + (last_seen_at > :last_seen_at) AND + uo.chat_enabled AND + uccm.id IS NULL + SQL + + query += <<~SQL if category.read_restricted? + AND cg.category_id = :channel_category + SQL + + query += "RETURNING user_chat_channel_memberships.user_id" + end + end +end diff --git a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb new file mode 100644 index 00000000000..6d579bc88ef --- /dev/null +++ b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Jobs + class AutoManageChannelMemberships < ::Jobs::Base + def execute(args) + channel = + ChatChannel.includes(:chatable).find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel&.chatable + + processed = + UserChatChannelMembership.where( + chat_channel: channel, + following: true, + join_mode: UserChatChannelMembership.join_modes[:automatic], + ).count + + auto_join_query(channel).find_in_batches do |batch| + break if processed >= SiteSetting.max_chat_auto_joined_users + + starts_at = batch.first.query_user_id + ends_at = batch.last.query_user_id + + Jobs.enqueue( + :auto_join_channel_batch, + chat_channel_id: channel.id, + starts_at: starts_at, + ends_at: ends_at, + ) + + processed += batch.size + end + + # The Jobs::AutoJoinChannelBatch job will only do this recalculation + # if it's operating on one user, so we need to make sure we do it for + # the channel here once this job is complete. + Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count + end + + private + + def auto_join_query(channel) + category = channel.chatable + + users = + User + .real + .activated + .not_suspended + .not_staged + .distinct + .select(:id, "users.id AS query_user_id") + .where("last_seen_at > ?", 3.months.ago) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .joins(<<~SQL) + LEFT OUTER JOIN user_chat_channel_memberships uccm + ON uccm.chat_channel_id = #{channel.id} AND + uccm.user_id = users.id + SQL + .where("uccm.id IS NULL") + + if category.read_restricted? + users = + users + .joins(:group_users) + .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") + .where("cg.category_id = ?", channel.chatable_id) + end + + users + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_archive.rb b/plugins/chat/app/jobs/regular/chat_channel_archive.rb new file mode 100644 index 00000000000..c5eb878d33b --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_channel_archive.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Jobs + class ChatChannelArchive < ::Jobs::Base + sidekiq_options retry: false + + def execute(args = {}) + channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id]) + + # this should not really happen, but better to do this than throw an error + if channel_archive.blank? + Rails.logger.warn( + "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", + ) + return + end + + return if channel_archive.complete? + + DistributedMutex.synchronize( + "archive_chat_channel_#{channel_archive.chat_channel_id}", + validity: 20.minutes, + ) { Chat::ChatChannelArchiveService.new(channel_archive).execute } + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_delete.rb b/plugins/chat/app/jobs/regular/chat_channel_delete.rb new file mode 100644 index 00000000000..ac89be4db99 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_channel_delete.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Jobs + class ChatChannelDelete < ::Jobs::Base + def execute(args = {}) + chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id]) + + # this should not really happen, but better to do this than throw an error + if chat_channel.blank? + Rails.logger.warn( + "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", + ) + return + end + + DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do + Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") + ChatMessage.transaction do + webhooks = IncomingChatWebhook.where(chat_channel: chat_channel) + ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all + webhooks.delete_all + end + + Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") + ChatDraft.where(chat_channel: chat_channel).delete_all + UserChatChannelMembership.where(chat_channel: chat_channel).delete_all + + Rails.logger.debug( + "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", + ) + ChatMessage.transaction do + chat_messages = ChatMessage.where(chat_channel: chat_channel) + message_ids = chat_messages.select(:id) + ChatMention.where(chat_message_id: message_ids).delete_all + ChatMessageRevision.where(chat_message_id: message_ids).delete_all + ChatMessageReaction.where(chat_message_id: message_ids).delete_all + + # if the uploads are not used anywhere else they will be deleted + # by the CleanUpUploads job in core + ChatUpload.where(chat_message_id: message_ids).delete_all + + # only the messages and the channel are Trashable, everything else gets + # permanently destroyed + chat_messages.update_all( + deleted_by_id: chat_channel.deleted_by_id, + deleted_at: Time.zone.now, + ) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb new file mode 100644 index 00000000000..69a02fa6bc7 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +module Jobs + class ChatNotifyMentioned < ::Jobs::Base + def execute(args = {}) + @chat_message = + ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + if @chat_message.nil? || + @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? + return + end + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @already_notified_user_ids = args[:already_notified_user_ids] || [] + user_ids_to_notify = args[:to_notify_ids_map] || {} + user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } + end + + private + + def get_memberships(user_ids) + query = + UserChatChannelMembership.includes(:user).where( + user_id: (user_ids - @already_notified_user_ids), + chat_channel_id: @chat_message.chat_channel_id, + ) + query = query.where(following: true) if @chat_channel.public_channel? + query + end + + def build_data_for(membership, identifier_type:) + data = { + chat_message_id: @chat_message.id, + chat_channel_id: @chat_channel.id, + mentioned_by_username: @creator.username, + is_direct_message_channel: @chat_channel.direct_message_channel?, + } + + data[:chat_channel_title] = @chat_channel.title( + membership.user, + ) unless @is_direct_message_channel + + return data if identifier_type == :direct_mentions + + case identifier_type + when :here_mentions + data[:identifier] = "here" + when :global_mentions + data[:identifier] = "all" + else + data[:is_group_mention] = true + end + + data + end + + def build_payload_for(membership, identifier_type:) + payload = { + notification_type: Notification.types[:chat_mention], + username: @creator.username, + tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + post_url: + "/chat/channel/#{@chat_channel.id}/#{@chat_channel.title(membership.user)}?messageId=#{@chat_message.id}", + } + + translation_prefix = + ( + if @chat_channel.direct_message_channel? + "discourse_push_notifications.popup.direct_message_chat_mention" + else + "discourse_push_notifications.popup.chat_mention" + end + ) + + translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" + identifier_text = + case identifier_type + when :here_mentions + "@here" + when :global_mentions + "@all" + when :direct_mentions + "" + else + "@#{identifier_type}" + end + + payload[:translated_title] = I18n.t( + "#{translation_prefix}.#{translation_suffix}", + username: @creator.username, + identifier: identifier_text, + channel: @chat_channel.title(membership.user), + ) + + payload + end + + def create_notification!(membership, notification_data) + is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) + + notification = + Notification.create!( + notification_type: Notification.types[:chat_mention], + user_id: membership.user_id, + high_priority: true, + data: notification_data.to_json, + read: is_read, + ) + ChatMention.create!( + notification: notification, + user: membership.user, + chat_message: @chat_message, + ) + end + + def send_notifications(membership, notification_data, os_payload) + create_notification!(membership, notification_data) + + if !membership.desktop_notifications_never? && !membership.muted? + MessageBus.publish( + "/chat/notification-alert/#{membership.user_id}", + os_payload, + user_ids: [membership.user_id], + ) + end + + if !membership.mobile_notifications_never? && !membership.muted? + PostAlerter.push_notification(membership.user, os_payload) + end + end + + def process_mentions(user_ids, mention_type) + memberships = get_memberships(user_ids) + + memberships.each do |membership| + notification_data = build_data_for(membership, identifier_type: mention_type) + payload = build_payload_for(membership, identifier_type: mention_type) + + send_notifications(membership, notification_data, payload) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_notify_watching.rb b/plugins/chat/app/jobs/regular/chat_notify_watching.rb new file mode 100644 index 00000000000..c2c2cd8681e --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat_notify_watching.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Jobs + class ChatNotifyWatching < ::Jobs::Base + def execute(args = {}) + @chat_message = + ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id]) + return if @chat_message.nil? + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @is_direct_message_channel = @chat_channel.direct_message_channel? + + always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + members = + UserChatChannelMembership + .includes(user: :groups) + .joins(user: :user_option) + .where(user_option: { chat_enabled: true }) + .where.not(user_id: args[:except_user_ids]) + .where(chat_channel_id: @chat_channel.id) + .where(following: true) + .where( + "desktop_notification_level = ? OR mobile_notification_level = ?", + always_notification_level, + always_notification_level, + ) + .merge(User.not_suspended) + + if @is_direct_message_channel + UserCommScreener + .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) + .allowing_actor_communication + .each do |user_id| + send_notifications(members.find { |member| member.user_id == user_id }) + end + else + members.each { |member| send_notifications(member) } + end + end + + def send_notifications(membership) + user = membership.user + guardian = Guardian.new(user) + return unless guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel) + return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) + return if online_user_ids.include?(user.id) + + translation_key = + ( + if @is_direct_message_channel + "discourse_push_notifications.popup.new_direct_chat_message" + else + "discourse_push_notifications.popup.new_chat_message" + end + ) + + translation_args = { username: @creator.username } + translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel + + payload = { + username: @creator.username, + notification_type: Notification.types[:chat_message], + post_url: "/chat/channel/#{@chat_channel.id}/#{@chat_channel.title(user)}", + translated_title: I18n.t(translation_key, translation_args), + tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + } + + if membership.desktop_notifications_always? && !membership.muted? + MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) + end + + if membership.mobile_notifications_always? && !membership.muted? + PostAlerter.push_notification(user, payload) + end + end + + def online_user_ids + @online_user_ids ||= PresenceChannel.new("/chat/online").user_ids + end + end +end diff --git a/plugins/chat/app/jobs/regular/process_chat_message.rb b/plugins/chat/app/jobs/regular/process_chat_message.rb new file mode 100644 index 00000000000..612978bb23f --- /dev/null +++ b/plugins/chat/app/jobs/regular/process_chat_message.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Jobs + class ProcessChatMessage < ::Jobs::Base + def execute(args = {}) + DistributedMutex.synchronize( + "process_chat_message_#{args[:chat_message_id]}", + validity: 10.minutes, + ) do + chat_message = ChatMessage.find_by(id: args[:chat_message_id]) + return if !chat_message + processor = Chat::ChatMessageProcessor.new(chat_message) + processor.run! + + if args[:is_dirty] || processor.dirty? + chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION) + ChatPublisher.publish_processed!(chat_message) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/update_channel_user_count.rb new file mode 100644 index 00000000000..0790a52e167 --- /dev/null +++ b/plugins/chat/app/jobs/regular/update_channel_user_count.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Jobs + class UpdateChannelUserCount < Jobs::Base + def execute(args = {}) + channel = ChatChannel.find_by(id: args[:chat_channel_id]) + return if channel.blank? + return if !channel.user_count_stale + + channel.update!( + user_count: ChatChannelMembershipsQuery.count(channel), + user_count_stale: false, + ) + + ChatPublisher.publish_chat_channel_metadata(channel) + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/auto_join_users.rb new file mode 100644 index 00000000000..061a3dce8db --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/auto_join_users.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + class AutoJoinUsers < ::Jobs::Scheduled + every 1.hour + + def execute(_args) + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb new file mode 100644 index 00000000000..07999155281 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Jobs + class DeleteOldChatMessages < ::Jobs::Scheduled + daily at: 0.hours + + def execute(args = {}) + delete_public_channel_messages + delete_dm_channel_messages + end + + def delete_public_channel_messages + return unless valid_day_value?(:chat_channel_retention_days) + + ChatMessage + .in_public_channel + .with_deleted + .created_before(SiteSetting.chat_channel_retention_days.days.ago) + .in_batches(of: 200) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read_message_id(destroyed_ids) + delete_flags(destroyed_ids) + end + end + + def delete_dm_channel_messages + return unless valid_day_value?(:chat_dm_retention_days) + + ChatMessage + .in_dm_channel + .with_deleted + .created_before(SiteSetting.chat_dm_retention_days.days.ago) + .in_batches(of: 200) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read_message_id(destroyed_ids) + end + end + + def valid_day_value?(setting_name) + (SiteSetting.public_send(setting_name) || 0).positive? + end + + def reset_last_read_message_id(ids) + UserChatChannelMembership.where(last_read_message_id: ids).update_all( + last_read_message_id: nil, + ) + end + + def delete_flags(message_ids) + ReviewableChatMessage.where(target_id: message_ids).destroy_all + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb new file mode 100644 index 00000000000..470c6aa2152 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Jobs + class EmailChatNotifications < ::Jobs::Scheduled + every 5.minutes + + def execute(args = {}) + return unless SiteSetting.chat_enabled + + Chat::ChatMailer.send_unread_mentions_summary + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb new file mode 100644 index 00000000000..968982819f2 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Jobs + class UpdateUserCountsForChatChannels < ::Jobs::Scheduled + every 1.hour + + # FIXME: This could become huge as the amount of channels grows, we + # need a different approach here. Perhaps we should only bother for + # channels updated or with new messages in the past N days? Perhaps + # we could update all the counts in a single query as well? + def execute(args = {}) + ChatChannel + .where(status: %i[open closed]) + .find_each { |chat_channel| set_user_count(chat_channel) } + end + + def set_user_count(chat_channel) + current_count = chat_channel.user_count || 0 + new_count = ChatChannelMembershipsQuery.count(chat_channel) + return if current_count == new_count + + chat_channel.update(user_count: new_count, user_count_stale: false) + ChatPublisher.publish_chat_channel_metadata(chat_channel) + end + end +end diff --git a/plugins/chat/app/models/category_channel.rb b/plugins/chat/app/models/category_channel.rb new file mode 100644 index 00000000000..d9561d7f1fc --- /dev/null +++ b/plugins/chat/app/models/category_channel.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CategoryChannel < ChatChannel + alias_attribute :category, :chatable + + delegate :read_restricted?, to: :category + delegate :url, to: :chatable, prefix: true + + %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| + define_method(name) { true } + end + + def allowed_group_ids + return if !read_restricted? + + staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values + category.secure_group_ids.to_a.concat(staff_groups) + end + + def title(_) + name.presence || category.name + end +end diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb new file mode 100644 index 00000000000..9afcfea8fe7 --- /dev/null +++ b/plugins/chat/app/models/chat_channel.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +class ChatChannel < ActiveRecord::Base + include Trashable + + belongs_to :chatable, polymorphic: true + belongs_to :direct_message_channel, + -> { where(chat_channels: { chatable_type: "DirectMessageChannel" }) }, + foreign_key: "chatable_id" + + has_many :chat_messages + has_many :user_chat_channel_memberships + + has_one :chat_channel_archive + + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + + validates :name, + length: { + maximum: Proc.new { SiteSetting.max_topic_title_length }, + }, + presence: true, + allow_nil: true + + scope :public_channels, + -> { + where(chatable_type: public_channel_chatable_types).where( + "categories.id IS NOT NULL", + ).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + } + + class << self + def public_channel_chatable_types + ["Category"] + end + + def chatable_types + public_channel_chatable_types << "DirectMessageChannel" + end + end + + statuses.keys.each do |status| + define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } + end + + %i[ + category_channel? + direct_message_channel? + public_channel? + chatable_has_custom_fields? + read_restricted? + ].each { |name| define_method(name) { false } } + + %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } + + def membership_for(user) + user_chat_channel_memberships.find_by(user: user) + end + + def add(user) + Chat::ChatChannelMembershipManager.new(self).follow(user) + end + + def remove(user) + Chat::ChatChannelMembershipManager.new(self).unfollow(user) + end + + def status_name + I18n.t("chat.channel.statuses.#{self.status}") + end + + def url + "#{Discourse.base_url}/chat/channel/#{self.id}/-" + end + + def public_channel_title + chatable.name + end + + private + + def change_status(acting_user, target_status) + return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) + self.update!(status: target_status) + log_channel_status_change(acting_user: acting_user) + end + + def log_channel_status_change(acting_user:) + DiscourseEvent.trigger( + :chat_channel_status_change, + channel: self, + old_status: status_previously_was, + new_status: status, + ) + + StaffActionLogger.new(acting_user).log_custom( + "chat_channel_status_change", + { + chat_channel_id: self.id, + chat_channel_name: self.name, + previous_value: status_previously_was, + new_value: status, + }, + ) + + ChatPublisher.publish_channel_status(self) + end +end + +# == Schema Information +# +# Table name: chat_channels +# +# id :bigint not null, primary key +# chatable_id :integer not null +# deleted_at :datetime +# deleted_by_id :integer +# featured_in_category_id :integer +# delete_after_seconds :integer +# chatable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# description :text +# status :integer default("open"), not null +# user_count :integer default(0), not null +# last_message_sent_at :datetime not null +# auto_join_users :boolean default(FALSE), not null +# user_count_stale :boolean default(FALSE), not null +# slug :string +# +# Indexes +# +# index_chat_channels_on_chatable_id (chatable_id) +# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) +# index_chat_channels_on_slug (slug) +# index_chat_channels_on_status (status) +# diff --git a/plugins/chat/app/models/chat_channel_archive.rb b/plugins/chat/app/models/chat_channel_archive.rb new file mode 100644 index 00000000000..e84cdb35e37 --- /dev/null +++ b/plugins/chat/app/models/chat_channel_archive.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ChatChannelArchive < ActiveRecord::Base + belongs_to :chat_channel + belongs_to :archived_by, class_name: "User" + + belongs_to :destination_topic, class_name: "Topic" + + def complete? + self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? + end + + def failed? + !complete? && self.archive_error.present? + end +end + +# == Schema Information +# +# Table name: chat_channel_archives +# +# id :bigint not null, primary key +# chat_channel_id :bigint not null +# archived_by_id :integer not null +# destination_topic_id :integer +# destination_topic_title :string +# destination_category_id :integer +# destination_tags :string is an Array +# total_messages :integer not null +# archived_messages :integer default(0), not null +# archive_error :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_channel_archives_on_chat_channel_id (chat_channel_id) +# diff --git a/plugins/chat/app/models/chat_draft.rb b/plugins/chat/app/models/chat_draft.rb new file mode 100644 index 00000000000..1d3781fa826 --- /dev/null +++ b/plugins/chat/app/models/chat_draft.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ChatDraft < ActiveRecord::Base + belongs_to :user + belongs_to :chat_channel +end + +# == Schema Information +# +# Table name: chat_drafts +# +# id :bigint not null, primary key +# user_id :integer not null +# chat_channel_id :integer not null +# data :text not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/chat_mention.rb b/plugins/chat/app/models/chat_mention.rb new file mode 100644 index 00000000000..e334acae473 --- /dev/null +++ b/plugins/chat/app/models/chat_mention.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ChatMention < ActiveRecord::Base + belongs_to :user + belongs_to :chat_message + belongs_to :notification +end + +# == Schema Information +# +# Table name: chat_mentions +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# user_id :integer not null +# notification_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_mentions_index (chat_message_id,user_id,notification_id) UNIQUE +# diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb new file mode 100644 index 00000000000..e327170b92a --- /dev/null +++ b/plugins/chat/app/models/chat_message.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +class ChatMessage < ActiveRecord::Base + include Trashable + attribute :has_oneboxes, default: false + + BAKED_VERSION = 2 + + belongs_to :chat_channel + belongs_to :user + belongs_to :in_reply_to, class_name: "ChatMessage" + has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify + has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy + has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy + has_many :bookmarks, as: :bookmarkable, dependent: :destroy + has_many :chat_uploads, dependent: :destroy + has_many :uploads, through: :chat_uploads + has_one :chat_webhook_event, dependent: :destroy + has_one :chat_mention, dependent: :destroy + + scope :in_public_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: ChatChannel.public_channel_chatable_types, + }, + ) + } + + scope :in_dm_channel, + -> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessageChannel" }) } + + scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } + + def validate_message(has_uploads:) + WatchedWordsValidator.new(attributes: [:message]).validate(self) + Chat::DuplicateMessageValidator.new(self).validate + + if !has_uploads && message_too_short? + self.errors.add( + :base, + I18n.t( + "chat.errors.minimum_length_not_met", + minimum: SiteSetting.chat_minimum_message_length, + ), + ) + end + end + + def attach_uploads(uploads) + return if uploads.blank? + + now = Time.now + record_attrs = + uploads.map do |upload| + { upload_id: upload.id, chat_message_id: self.id, created_at: now, updated_at: now } + end + ChatUpload.insert_all!(record_attrs) + end + + def excerpt + # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes + return message if UrlHelper.relaxed_parse(message).is_a?(URI) + + # upload-only messages are better represented as the filename + return uploads.first.original_filename if cooked.blank? && uploads.present? + + # this may return blank for some complex things like quotes, that is acceptable + PrettyText.excerpt(cooked, 50, {}) + end + + def cooked_for_excerpt + (cooked.blank? && uploads.present?) ? "

#{uploads.first.original_filename}

" : cooked + end + + def push_notification_excerpt + Emoji.gsub_emoji_to_unicode(message).truncate(400) + end + + def to_markdown + markdown = [] + + if self.message.present? + msg = self.message + + self.chat_uploads.any? ? markdown << msg + "\n" : markdown << msg + end + + self + .chat_uploads + .order(:created_at) + .each { |chat_upload| markdown << UploadMarkdown.new(chat_upload.upload).to_markdown } + + markdown.reject(&:empty?).join("\n") + end + + def cook + self.cooked = self.class.cook(self.message) + self.cooked_version = BAKED_VERSION + end + + def rebake!(invalidate_oneboxes: false, priority: nil) + previous_cooked = self.cooked + new_cooked = self.class.cook(message, invalidate_oneboxes: invalidate_oneboxes) + update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) + args = { chat_message_id: self.id } + args[:queue] = priority.to_s if priority && priority != :normal + args[:is_dirty] = true if previous_cooked != new_cooked + + Jobs.enqueue(:process_chat_message, args) + end + + def self.uncooked + where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) + end + + MARKDOWN_FEATURES = %w[ + anchor + bbcode-block + bbcode-inline + code + category-hashtag + censored + chat-transcript + discourse-local-dates + emoji + emojiShortcuts + inlineEmoji + html-img + mentions + unicodeUsernames + onebox + quotes + spoiler-alert + table + text-post-process + upload-protocol + watched-words + ] + + MARKDOWN_IT_RULES = %w[ + autolink + list + backticks + newline + code + fence + image + table + linkify + link + strikethrough + blockquote + emphasis + ] + + def self.cook(message, opts = {}) + cooked = + PrettyText.cook( + message, + features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, + markdown_it_rules: MARKDOWN_IT_RULES, + force_quote_link: true, + ) + + result = + Oneboxer.apply(cooked) do |url| + if opts[:invalidate_oneboxes] + Oneboxer.invalidate(url) + InlineOneboxer.invalidate(url) + end + onebox = Oneboxer.cached_onebox(url) + onebox + end + + cooked = result.to_html if result.changed? + cooked + end + + def full_url + "#{Discourse.base_url}#{url}" + end + + def url + "/chat/message/#{self.id}" + end + + private + + def message_too_short? + message.length < SiteSetting.chat_minimum_message_length + end +end + +# == Schema Information +# +# Table name: chat_messages +# +# id :bigint not null, primary key +# chat_channel_id :integer not null +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# deleted_by_id :integer +# in_reply_to_id :integer +# message :text +# cooked :text +# cooked_version :integer +# +# Indexes +# +# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) +# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) +# diff --git a/plugins/chat/app/models/chat_message_reaction.rb b/plugins/chat/app/models/chat_message_reaction.rb new file mode 100644 index 00000000000..f101b2ec353 --- /dev/null +++ b/plugins/chat/app/models/chat_message_reaction.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ChatMessageReaction < ActiveRecord::Base + belongs_to :chat_message + belongs_to :user +end + +# == Schema Information +# +# Table name: chat_message_reactions +# +# id :bigint not null, primary key +# chat_message_id :integer +# user_id :integer +# emoji :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_message_reactions_index (chat_message_id,user_id,emoji) UNIQUE +# diff --git a/plugins/chat/app/models/chat_message_revision.rb b/plugins/chat/app/models/chat_message_revision.rb new file mode 100644 index 00000000000..03f19168350 --- /dev/null +++ b/plugins/chat/app/models/chat_message_revision.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ChatMessageRevision < ActiveRecord::Base + belongs_to :chat_message +end + +# == Schema Information +# +# Table name: chat_message_revisions +# +# id :bigint not null, primary key +# chat_message_id :integer +# old_message :text not null +# new_message :text not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_message_revisions_on_chat_message_id (chat_message_id) +# diff --git a/plugins/chat/app/models/chat_upload.rb b/plugins/chat/app/models/chat_upload.rb new file mode 100644 index 00000000000..3382328bfd7 --- /dev/null +++ b/plugins/chat/app/models/chat_upload.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ChatUpload < ActiveRecord::Base + belongs_to :chat_message + belongs_to :upload +end + +# == Schema Information +# +# Table name: chat_uploads +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# upload_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_uploads_on_chat_message_id_and_upload_id (chat_message_id,upload_id) UNIQUE +# diff --git a/plugins/chat/app/models/chat_view.rb b/plugins/chat/app/models/chat_view.rb new file mode 100644 index 00000000000..9df0df18ddf --- /dev/null +++ b/plugins/chat/app/models/chat_view.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class ChatView + attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future + + def initialize( + chat_channel:, + chat_messages:, + user:, + can_load_more_past: nil, + can_load_more_future: nil + ) + @chat_channel = chat_channel + @chat_messages = chat_messages + @user = user + @can_load_more_past = can_load_more_past + @can_load_more_future = can_load_more_future + end + + def reviewable_ids + return @reviewable_ids if defined?(@reviewable_ids) + + @reviewable_ids = @user.staff? ? get_reviewable_ids : nil + end + + def user_flag_statuses + return @user_flag_statuses if defined?(@user_flag_statuses) + + @user_flag_statuses = get_user_flag_statuses + end + + private + + def get_reviewable_ids + sql = <<~SQL + SELECT + target_id, + MAX(r.id) reviewable_id + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + r.target_id IN (:message_ids) AND + r.target_type = 'ChatMessage' AND + s.status = :pending + GROUP BY + target_id + SQL + + ids = {} + + DB + .query( + sql, + pending: ReviewableScore.statuses[:pending], + message_ids: @chat_messages.map(&:id), + ) + .each { |row| ids[row.target_id] = row.reviewable_id } + + ids + end + + def get_user_flag_statuses + sql = <<~SQL + SELECT + target_id, + s.status + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + s.user_id = :user_id AND + r.target_id IN (:message_ids) AND + r.target_type = 'ChatMessage' + SQL + + statuses = {} + + DB + .query(sql, message_ids: @chat_messages.map(&:id), user_id: @user.id) + .each { |row| statuses[row.target_id] = row.status } + + statuses + end +end diff --git a/plugins/chat/app/models/chat_webhook_event.rb b/plugins/chat/app/models/chat_webhook_event.rb new file mode 100644 index 00000000000..acda4ffd9b0 --- /dev/null +++ b/plugins/chat/app/models/chat_webhook_event.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ChatWebhookEvent < ActiveRecord::Base + belongs_to :chat_message + belongs_to :incoming_chat_webhook + + delegate :username, to: :incoming_chat_webhook + delegate :emoji, to: :incoming_chat_webhook +end + +# == Schema Information +# +# Table name: chat_webhook_events +# +# id :bigint not null, primary key +# chat_message_id :integer not null +# incoming_chat_webhook_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# chat_webhook_events_index (chat_message_id,incoming_chat_webhook_id) UNIQUE +# diff --git a/plugins/chat/app/models/concerns/chatable.rb b/plugins/chat/app/models/concerns/chatable.rb new file mode 100644 index 00000000000..b70fe3b0c5a --- /dev/null +++ b/plugins/chat/app/models/concerns/chatable.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Chatable + extend ActiveSupport::Concern + + def chat_channel + channel_class.new(chatable: self) + end + + def create_chat_channel!(**args) + channel_class.create!(args.merge(chatable: self)) + end + + private + + def channel_class + case self + when Category + CategoryChannel + when DirectMessageChannel + DMChannel + else + raise "Unknown chatable #{self}" + end + end +end diff --git a/plugins/chat/app/models/d_m_channel.rb b/plugins/chat/app/models/d_m_channel.rb new file mode 100644 index 00000000000..a00503200c3 --- /dev/null +++ b/plugins/chat/app/models/d_m_channel.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# TODO: merge DMChannel and DirectMessageChannel models together +class DMChannel < ChatChannel + alias_attribute :direct_message_channel, :chatable + + def direct_message_channel? + true + end + + def allowed_user_ids + direct_message_channel.user_ids + end + + def read_restricted? + true + end + + def title(user) + direct_message_channel.chat_channel_title_for_user(self, user) + end +end diff --git a/plugins/chat/app/models/deleted_chat_user.rb b/plugins/chat/app/models/deleted_chat_user.rb new file mode 100644 index 00000000000..3d6222a4a9b --- /dev/null +++ b/plugins/chat/app/models/deleted_chat_user.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DeletedChatUser < User + def username + I18n.t("chat.deleted_chat_username") + end + + def avatar_template + "/plugins/chat/images/deleted-chat-user-avatar.png" + end + + def bot? + false + end +end diff --git a/plugins/chat/app/models/direct_message_channel.rb b/plugins/chat/app/models/direct_message_channel.rb new file mode 100644 index 00000000000..c71de383d11 --- /dev/null +++ b/plugins/chat/app/models/direct_message_channel.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class DirectMessageChannel < ActiveRecord::Base + include Chatable + + has_many :direct_message_users + has_many :users, through: :direct_message_users + + def self.for_user_ids(user_ids) + joins(:users) + .group("direct_message_channels.id") + .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) + &.first + end + + def user_can_access?(user) + users.include?(user) + end + + def chat_channel_title_for_user(chat_channel, acting_user) + users = + (direct_message_users.map(&:user) - [acting_user]).map { |user| user || DeletedChatUser.new } + + # direct message to self + if users.empty? + return I18n.t("chat.channel.dm_title.single_user", user: "@#{acting_user.username}") + end + + # all users deleted + return chat_channel.id if !users.first + + usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } + if usernames_formatted.size > 5 + return( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + users: usernames_formatted[0..4].join(", "), + leftover: usernames_formatted.length - 5, + ) + ) + end + + I18n.t("chat.channel.dm_title.multi_user", users: usernames_formatted.join(", ")) + end +end + +# == Schema Information +# +# Table name: direct_message_channels +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/direct_message_user.rb b/plugins/chat/app/models/direct_message_user.rb new file mode 100644 index 00000000000..3610479acc8 --- /dev/null +++ b/plugins/chat/app/models/direct_message_user.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DirectMessageUser < ActiveRecord::Base + belongs_to :direct_message_channel + belongs_to :user +end + +# == Schema Information +# +# Table name: direct_message_users +# +# id :bigint not null, primary key +# direct_message_channel_id :integer not null +# user_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# direct_message_users_index (direct_message_channel_id,user_id) UNIQUE +# diff --git a/plugins/chat/app/models/incoming_chat_webhook.rb b/plugins/chat/app/models/incoming_chat_webhook.rb new file mode 100644 index 00000000000..e71b539a037 --- /dev/null +++ b/plugins/chat/app/models/incoming_chat_webhook.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class IncomingChatWebhook < ActiveRecord::Base + belongs_to :chat_channel + has_many :chat_webhook_events + + before_create { self.key = SecureRandom.hex(12) } + + def url + "#{Discourse.base_url}/chat/hooks/#{key}.json" + end +end + +# == Schema Information +# +# Table name: incoming_chat_webhooks +# +# id :bigint not null, primary key +# name :string not null +# key :string not null +# chat_channel_id :integer not null +# username :string +# description :string +# emoji :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_incoming_chat_webhooks_on_key_and_chat_channel_id (key,chat_channel_id) +# diff --git a/plugins/chat/app/models/reviewable_chat_message.rb b/plugins/chat/app/models/reviewable_chat_message.rb new file mode 100644 index 00000000000..46bc6dd0715 --- /dev/null +++ b/plugins/chat/app/models/reviewable_chat_message.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_dependency "reviewable" + +class ReviewableChatMessage < Reviewable + def self.action_aliases + { + agree_and_keep_hidden: :agree_and_delete, + agree_and_silence: :agree_and_delete, + agree_and_suspend: :agree_and_delete, + delete_and_agree: :agree_and_delete, + disagree_and_restore: :disagree, + } + end + + def self.score_to_silence_user + sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) + end + + def chat_message + @chat_message ||= (target || ChatMessage.with_deleted.find_by(id: target_id)) + end + + def chat_message_creator + @chat_message_creator ||= chat_message.user + end + + def flagged_by_user_ids + @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) + end + + def post + nil + end + + def build_actions(actions, guardian, args) + return unless pending? + return if chat_message.blank? + + agree = + actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") + + if chat_message.deleted_at? + build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) + build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree_and_restore, icon: "thumbs-down") + else + build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) + build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree, icon: "thumbs-down") + end + + if guardian.can_suspend?(chat_message_creator) + build_action( + actions, + :agree_and_suspend, + icon: "ban", + bundle: agree, + client_action: "suspend", + ) + build_action( + actions, + :agree_and_silence, + icon: "microphone-slash", + bundle: agree, + client_action: "silence", + ) + end + + build_action(actions, :ignore, icon: "external-link-alt") + + build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at? + end + + def perform_agree_and_keep_message(performed_by, args) + agree + end + + def perform_agree_and_restore(performed_by, args) + agree { chat_message.recover! } + end + + def perform_agree_and_delete(performed_by, args) + agree { chat_message.trash!(performed_by) } + end + + def perform_disagree_and_restore(performed_by, args) + disagree { chat_message.recover! } + end + + def perform_disagree(performed_by, args) + disagree + end + + def perform_ignore(performed_by, args) + ignore + end + + def perform_delete_and_ignore(performed_by, args) + ignore { chat_message.trash!(performed_by) } + end + + private + + def agree + yield if block_given? + create_result(:success, :approved) do |result| + result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def disagree + yield if block_given? + create_result(:success, :rejected) do |result| + result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def ignore + yield if block_given? + create_result(:success, :ignored) do |result| + result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } + end + end + + def build_action( + actions, + id, + icon:, + button_class: nil, + bundle: nil, + client_action: nil, + confirm: false + ) + actions.add(id, bundle: bundle) do |action| + prefix = "reviewables.actions.#{id}" + action.icon = icon + action.button_class = button_class + action.label = "chat.#{prefix}.title" + action.description = "chat.#{prefix}.description" + action.client_action = client_action + action.confirm_message = "#{prefix}.confirm" if confirm + end + end +end diff --git a/plugins/chat/app/models/user_chat_channel_membership.rb b/plugins/chat/app/models/user_chat_channel_membership.rb new file mode 100644 index 00000000000..643dcdb1a6e --- /dev/null +++ b/plugins/chat/app/models/user_chat_channel_membership.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class UserChatChannelMembership < ActiveRecord::Base + NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } + + belongs_to :user + belongs_to :chat_channel + belongs_to :last_read_message, class_name: "ChatMessage", optional: true + + enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications + enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications + enum :join_mode, { manual: 0, automatic: 1 } + + attribute :unread_count, default: 0 + attribute :unread_mentions, default: 0 +end + +# == Schema Information +# +# Table name: user_chat_channel_memberships +# +# id :bigint not null, primary key +# user_id :integer not null +# chat_channel_id :integer not null +# last_read_message_id :integer +# following :boolean default(FALSE), not null +# muted :boolean default(FALSE), not null +# desktop_notification_level :integer default("mention"), not null +# mobile_notification_level :integer default("mention"), not null +# created_at :datetime not null +# updated_at :datetime not null +# last_unread_mention_when_emailed_id :integer +# join_mode :integer default("manual"), not null +# +# Indexes +# +# user_chat_channel_memberships_index (user_id,chat_channel_id,desktop_notification_level,mobile_notification_level,following) +# user_chat_channel_unique_memberships (user_id,chat_channel_id) UNIQUE +# diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb new file mode 100644 index 00000000000..a257e0c0697 --- /dev/null +++ b/plugins/chat/app/queries/chat_channel_memberships_query.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ChatChannelMembershipsQuery + def self.call(channel, limit: 50, offset: 0, username: nil, count_only: false) + query = + UserChatChannelMembership + .joins(:user) + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .where(chat_channel: channel, following: true) + + return query.count if count_only + + if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids + query = + query.where( + "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", + channel.allowed_group_ids, + ) + end + + if username.present? + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.where("users.username_lower ILIKE ?", "%#{username}%") + else + query = + query.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + "%#{username}%", + "%#{username}%", + ) + end + end + + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.order("users.username_lower ASC") + else + query = query.order("users.name ASC, users.username_lower ASC") + end + + query.offset(offset).limit(limit) + end + + def self.count(channel) + call(channel, count_only: true) + end +end diff --git a/plugins/chat/app/serializers/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/admin_chat_index_serializer.rb new file mode 100644 index 00000000000..c8af0dc2f19 --- /dev/null +++ b/plugins/chat/app/serializers/admin_chat_index_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AdminChatIndexSerializer < ApplicationSerializer + has_many :chat_channels, serializer: ChatChannelSerializer, embed: :objects + has_many :incoming_chat_webhooks, serializer: IncomingChatWebhookSerializer, embed: :objects + + def chat_channels + object[:chat_channels] + end + + def incoming_chat_webhooks + object[:incoming_chat_webhooks] + end +end diff --git a/plugins/chat/app/serializers/chat_channel_index_serializer.rb b/plugins/chat/app/serializers/chat_channel_index_serializer.rb new file mode 100644 index 00000000000..59c555a90f7 --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_index_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChatChannelIndexSerializer < StructuredChannelSerializer + attributes :global_presence_channel_state + + def global_presence_channel_state + PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) + end +end diff --git a/plugins/chat/app/serializers/chat_channel_search_serializer.rb b/plugins/chat/app/serializers/chat_channel_search_serializer.rb new file mode 100644 index 00000000000..cf5bc083cc9 --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_search_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChatChannelSearchSerializer < StructuredChannelSerializer + has_many :users, serializer: BasicUserSerializer, embed: :objects + + def users + object[:users] + end +end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb new file mode 100644 index 00000000000..8ef5a0831cb --- /dev/null +++ b/plugins/chat/app/serializers/chat_channel_serializer.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class ChatChannelSerializer < ApplicationSerializer + attributes :id, + :auto_join_users, + :chatable, + :chatable_id, + :chatable_type, + :chatable_url, + :description, + :title, + :last_message_sent_at, + :status, + :archive_failed, + :archive_completed, + :archived_messages, + :total_messages, + :archive_topic_id, + :memberships_count, + :current_user_membership + + def initialize(object, opts) + super(object, opts) + @current_user_membership = opts[:membership] + end + + def include_description? + object.description.present? + end + + def memberships_count + object.user_count + end + + def chatable_url + object.chatable_url + end + + def title + object.name || object.title(scope.user) + end + + def chatable + case object.chatable_type + when "Category" + BasicCategorySerializer.new(object.chatable, root: false).as_json + when "DirectMessageChannel" + DirectMessageChannelSerializer.new(object.chatable, scope: scope, root: false).as_json + when "Site" + nil + end + end + + def archive + object.chat_channel_archive + end + + def include_archive_status? + scope.is_staff? && object.archived? && archive.present? + end + + def archive_completed + archive.complete? + end + + def archive_failed + archive.failed? + end + + def archived_messages + archive.archived_messages + end + + def total_messages + archive.total_messages + end + + def archive_topic_id + archive.destination_topic_id + end + + def include_auto_join_users? + scope.can_edit_chat_channel? + end + + def current_user_membership + return if !@current_user_membership + @current_user_membership.chat_channel = object + UserChatChannelMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + alias_method :include_archive_topic_id?, :include_archive_status? + alias_method :include_total_messages?, :include_archive_status? + alias_method :include_archived_messages?, :include_archive_status? + alias_method :include_archive_failed?, :include_archive_status? + alias_method :include_archive_completed?, :include_archive_status? +end diff --git a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb new file mode 100644 index 00000000000..25cb08c8fde --- /dev/null +++ b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ChatInReplyToSerializer < ApplicationSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects + + attributes :id, :cooked, :excerpt + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def user + object.user || DeletedChatUser.new + end +end diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb new file mode 100644 index 00000000000..0bcbd64c3d0 --- /dev/null +++ b/plugins/chat/app/serializers/chat_message_serializer.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +class ChatMessageSerializer < ApplicationSerializer + attributes :id, + :message, + :cooked, + :created_at, + :excerpt, + :deleted_at, + :deleted_by_id, + :reviewable_id, + :user_flag_status, + :edited, + :reactions, + :bookmark, + :available_flags + + has_one :user, serializer: BasicUserWithStatusSerializer, embed: :objects + has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects + has_one :in_reply_to, serializer: ChatInReplyToSerializer, embed: :objects + has_many :uploads, serializer: UploadSerializer, embed: :objects + + def user + object.user || DeletedChatUser.new + end + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def reactions + reactions_hash = {} + object + .reactions + .group_by(&:emoji) + .each do |emoji, reactions| + users = reactions[0..6].map(&:user).filter { |user| user.id != scope&.user&.id }[0..5] + + next unless Emoji.exists?(emoji) + + reactions_hash[emoji] = { + count: reactions.count, + users: + ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, + reacted: users_reactions.include?(emoji), + } + end + reactions_hash + end + + def include_reactions? + object.reactions.any? + end + + def users_reactions + @users_reactions ||= + object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) + end + + def users_bookmark + @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } + end + + def include_bookmark? + users_bookmark.present? + end + + def bookmark + { + id: users_bookmark.id, + reminder_at: users_bookmark.reminder_at, + name: users_bookmark.name, + auto_delete_preference: users_bookmark.auto_delete_preference, + bookmarkable_id: users_bookmark.bookmarkable_id, + bookmarkable_type: users_bookmark.bookmarkable_type, + } + end + + def edited + true + end + + def include_edited? + object.revisions.any? + end + + def deleted_at + object.user ? object.deleted_at : Time.zone.now + end + + def deleted_by_id + object.user ? object.deleted_by_id : Discourse.system_user.id + end + + def include_deleted_at? + object.user ? !object.deleted_at.nil? : true + end + + def include_deleted_by_id? + object.user ? !object.deleted_at.nil? : true + end + + def include_in_reply_to? + object.in_reply_to_id.presence + end + + def reviewable_id + return @reviewable_id if defined?(@reviewable_id) + return @reviewable_id = nil unless @options && @options[:reviewable_ids] + + @reviewable_id = @options[:reviewable_ids][object.id] + end + + def include_reviewable_id? + reviewable_id.present? + end + + def user_flag_status + return @user_flag_status if defined?(@user_flag_status) + return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) + + @user_flag_status = @options[:user_flag_statuses][object.id] + end + + def include_user_flag_status? + user_flag_status.present? + end + + def available_flags + return [] if !scope.can_flag_chat_message?(object) + return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] + + channel = @options.dig(:chat_channel) || object.chat_channel + + PostActionType.flag_types.map do |sym, id| + next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) + + if sym == :notify_user && + ( + scope.current_user == user || user.bot? || + !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) + ) + next + end + + sym + end + end +end diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb new file mode 100644 index 00000000000..54b78b84015 --- /dev/null +++ b/plugins/chat/app/serializers/chat_view_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ChatViewSerializer < ApplicationSerializer + attributes :meta, :chat_messages + + def chat_messages + ActiveModel::ArraySerializer.new( + object.chat_messages, + each_serializer: ChatMessageSerializer, + reviewable_ids: object.reviewable_ids, + user_flag_statuses: object.user_flag_statuses, + chat_channel: object.chat_channel, + scope: scope, + ) + end + + def meta + meta_hash = { + can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), + channel_status: object.chat_channel.status, + user_silenced: !scope.can_create_chat_message?, + can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), + can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), + can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), + } + meta_hash[:can_load_more_past] = object.can_load_more_past unless object.can_load_more_past.nil? + meta_hash[ + :can_load_more_future + ] = object.can_load_more_future unless object.can_load_more_future.nil? + meta_hash + end +end diff --git a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb new file mode 100644 index 00000000000..3fb674c653f --- /dev/null +++ b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ChatWebhookEventSerializer < ApplicationSerializer + attributes :username, :emoji +end diff --git a/plugins/chat/app/serializers/direct_message_channel_serializer.rb b/plugins/chat/app/serializers/direct_message_channel_serializer.rb new file mode 100644 index 00000000000..33a044b7ff6 --- /dev/null +++ b/plugins/chat/app/serializers/direct_message_channel_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class DirectMessageChannelSerializer < ApplicationSerializer + has_many :users, serializer: UserWithCustomFieldsAndStatusSerializer, embed: :objects + + def users + users = object.direct_message_users.map(&:user).map { |u| u || DeletedChatUser.new } + + return users - [scope.user] if users.count > 1 + users + end +end diff --git a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb new file mode 100644 index 00000000000..7f097e62bfd --- /dev/null +++ b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class IncomingChatWebhookSerializer < ApplicationSerializer + has_one :chat_channel, serializer: ChatChannelSerializer, embed: :objects + + attributes :id, :name, :description, :emoji, :url, :username, :updated_at +end diff --git a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb new file mode 100644 index 00000000000..5c56d39fb70 --- /dev/null +++ b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_dependency "reviewable_serializer" + +class ReviewableChatMessageSerializer < ReviewableSerializer + target_attributes :cooked + payload_attributes :transcript_topic_id, :message_cooked + attributes :target_id + + has_one :chat_channel, serializer: ChatChannelSerializer, root: false, embed: :objects + + def chat_channel + object.chat_message.chat_channel + end + + def target_id + object.target&.id + end +end diff --git a/plugins/chat/app/serializers/structured_channel_serializer.rb b/plugins/chat/app/serializers/structured_channel_serializer.rb new file mode 100644 index 00000000000..e3ee4f7783b --- /dev/null +++ b/plugins/chat/app/serializers/structured_channel_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class StructuredChannelSerializer < ApplicationSerializer + attributes :public_channels, :direct_message_channels + + def public_channels + object[:public_channels].map do |channel| + ChatChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + ) + end + end + + def direct_message_channels + object[:direct_message_channels].map do |channel| + ChatChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + ) + end + end + + def channel_membership(channel_id) + return if scope.anonymous? + object[:memberships].find { |membership| membership.chat_channel_id == channel_id } + end +end diff --git a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb new file mode 100644 index 00000000000..93e7e5bd85c --- /dev/null +++ b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class UserChatChannelMembershipSerializer < ApplicationSerializer + attributes :following, + :muted, + :desktop_notification_level, + :mobile_notification_level, + :chat_channel_id, + :last_read_message_id, + :unread_count, + :unread_mentions + + has_one :user, serializer: BasicUserSerializer, embed: :objects + + def user + object.user + end +end diff --git a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb new file mode 100644 index 00000000000..49f4c7af6f6 --- /dev/null +++ b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class UserChatMessageBookmarkSerializer < UserBookmarkBaseSerializer + attr_reader :chat_message + + def title + fancy_title + end + + def fancy_title + @fancy_title ||= chat_message.chat_channel.title(scope.user) + end + + def cooked + chat_message.cooked + end + + def bookmarkable_user + @bookmarkable_user ||= chat_message.user + end + + def bookmarkable_url + chat_message.url + end + + def excerpt + return nil unless cooked + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + private + + def chat_message + object.bookmarkable + end +end diff --git a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb new file mode 100644 index 00000000000..e0897abfd54 --- /dev/null +++ b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class UserWithCustomFieldsAndStatusSerializer < UserWithCustomFieldsSerializer + attributes :status + + def include_status? + SiteSetting.enable_user_status && user.has_status? + end + + def status + UserStatusSerializer.new(user.user_status, root: false) + end +end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb new file mode 100644 index 00000000000..ab7c8bea28a --- /dev/null +++ b/plugins/chat/app/services/chat_publisher.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module ChatPublisher + def self.publish_new!(chat_channel, chat_message, staged_id) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :sent + content[:stagedId] = staged_id + permissions = permissions(chat_channel) + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) + MessageBus.publish( + "/chat/#{chat_channel.id}/new-messages", + { + message_id: chat_message.id, + user_id: chat_message.user.id, + username: chat_message.user.username, + }, + permissions, + ) + end + + def self.publish_processed!(chat_message) + chat_channel = chat_message.chat_channel + content = { + type: :processed, + chat_message: { + id: chat_message.id, + cooked: chat_message.cooked, + }, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_edit!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :edit + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_refresh!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :refresh + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) + content = { + action: action, + user: BasicUserSerializer.new(user, root: false).as_json, + emoji: emoji, + type: :reaction, + chat_message_id: chat_message.id, + } + MessageBus.publish( + "/chat/message-reactions/#{chat_message.id}", + content.as_json, + permissions(chat_channel), + ) + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_presence!(chat_channel, user, typ) + raise NotImplementedError + end + + def self.publish_delete!(chat_channel, chat_message) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, + permissions(chat_channel), + ) + end + + def self.publish_bulk_delete!(chat_channel, deleted_message_ids) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, + permissions(chat_channel), + ) + end + + def self.publish_restore!(chat_channel, chat_message) + content = + ChatMessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :restore + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_flag!(chat_message, user, reviewable, score) + # Publish to user who created flag + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: "self_flagged", + user_flag_status: score.status_for_database, + chat_message_id: chat_message.id, + }.as_json, + user_ids: [user.id], + ) + + # Publish flag with link to reviewable to staff + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, + group_ids: [Group::AUTO_GROUPS[:staff]], + ) + end + + def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + MessageBus.publish( + "/chat/user-tracking-state/#{user.id}", + { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id.to_i }.as_json, + user_ids: [user.id], + ) + end + + def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) + MessageBus.publish( + "/chat/#{chat_channel_id}/new-mentions", + { message_id: chat_message_id }.as_json, + user_ids: [user_id], + ) + end + + def self.publish_new_channel(chat_channel, users) + users.each do |user| + serialized_channel = + ChatChannelSerializer.new( + chat_channel, + scope: Guardian.new(user), # We need a guardian here for direct messages + root: :chat_channel, + membership: chat_channel.membership_for(user), + ).as_json + MessageBus.publish("/chat/new-channel", serialized_channel, user_ids: [user.id]) + end + end + + def self.publish_inaccessible_mentions( + user_id, + chat_message, + cannot_chat_users, + without_membership + ) + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: :mention_warning, + chat_message_id: chat_message.id, + cannot_see: + ActiveModel::ArraySerializer.new( + cannot_chat_users, + each_serializer: BasicUserSerializer, + ).as_json, + without_membership: + ActiveModel::ArraySerializer.new( + without_membership, + each_serializer: BasicUserSerializer, + ).as_json, + }, + user_ids: [user_id], + ) + end + + def self.publish_chat_channel_edit(chat_channel, acting_user) + MessageBus.publish( + "/chat/channel-edits", + { + chat_channel_id: chat_channel.id, + name: chat_channel.title(acting_user), + description: chat_channel.description, + }, + permissions(chat_channel), + ) + end + + def self.publish_channel_status(chat_channel) + MessageBus.publish( + "/chat/channel-status", + { chat_channel_id: chat_channel.id, status: chat_channel.status }, + permissions(chat_channel), + ) + end + + def self.publish_chat_channel_metadata(chat_channel) + MessageBus.publish( + "/chat/channel-metadata", + { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, + permissions(chat_channel), + ) + end + + def self.publish_archive_status( + chat_channel, + archive_status:, + archived_messages:, + archive_topic_id:, + total_messages: + ) + MessageBus.publish( + "/chat/channel-archive-status", + { + chat_channel_id: chat_channel.id, + archive_failed: archive_status == :failed, + archive_completed: archive_status == :success, + archived_messages: archived_messages, + total_messages: total_messages, + archive_topic_id: archive_topic_id, + }, + permissions(chat_channel), + ) + end + + private + + def self.permissions(chat_channel) + { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } + end + + def self.anonymous_guardian + Guardian.new(nil) + end +end diff --git a/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb b/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb new file mode 100644 index 00000000000..bf88c732054 --- /dev/null +++ b/plugins/chat/app/views/connectors/unsubscribe_options/chat_frequencies.html.erb @@ -0,0 +1,10 @@ +<% if @chat_email_frequencies %> +

+ + <%= + select_tag :chat_email_frequency, + options_for_select(@chat_email_frequencies, @current_chat_email_frequency), + class: 'combobox' + %> +

+<% end %> diff --git a/plugins/chat/app/views/user_notifications/chat_summary.html.erb b/plugins/chat/app/views/user_notifications/chat_summary.html.erb new file mode 100644 index 00000000000..d3a235d50df --- /dev/null +++ b/plugins/chat/app/views/user_notifications/chat_summary.html.erb @@ -0,0 +1,84 @@ +
+ + + + + + + +
+ + + <%- if logo_url.blank? %> + <%= SiteSetting.title %> + <%- else %> + <%= SiteSetting.title %> + <%- end %> + + +
+ <%= I18n.t("user_notifications.chat_summary.description", count: @messages.size) %> +
+ + <%- @grouped_messages.each do |chat_channel, messages| %> + <%- other_messages_count = messages.size - 2 %> + + + + + + <%- messages.take(2).each do |chat_message| %> + <%- sender = chat_message.user %> + <%- sender_name = @display_usernames ? sender.username : sender.name %> + + + + + + + + <%- end %> + + + + +
+
<%= chat_channel.title(@user) %>
+
+ <%= sender_name -%> + + <%= sender_name -%> + + + <%= I18n.l(@user_tz.to_local(chat_message.created_at), format: :long) -%> + +
+ <%= email_excerpt(chat_message.cooked_for_excerpt) %> +
+ + <%- if other_messages_count <= 0 %> + <%= I18n.t("user_notifications.chat_summary.view_messages", count: messages.size)%> + <%- else %> + <%= I18n.t("user_notifications.chat_summary.view_more", count: other_messages_count)%> + <%- end %> + +
+ <%- end %> +
+ + + + + + diff --git a/plugins/chat/app/views/user_notifications/chat_summary.text.erb b/plugins/chat/app/views/user_notifications/chat_summary.text.erb new file mode 100644 index 00000000000..76955166f24 --- /dev/null +++ b/plugins/chat/app/views/user_notifications/chat_summary.text.erb @@ -0,0 +1,15 @@ +<%- site_link = raw(@markdown_linker.create(@site_name, '/')) %> +<%= t('user_notifications.chat_summary.description', count: @messages.size,) %> +<%= raw(@markdown_linker.create(t("user_notifications.chat_summary.view_messages", count: @messages.size), "/chat")) %> +<%- if @unsubscribe_link %> + <%= raw(t :'user_notifications.chat_summary.unsubscribe', + site_link: site_link, + email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path), + unsubscribe_link: @markdown_linker.create(t('user_notifications.digest.click_here'), @unsubscribe_link)) %> +<%- else %> + <%= raw(t :'user_notifications.chat_summary.unsubscribe_no_link', + site_link: site_link, + email_preferences_link: @markdown_linker.create(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path)) %> +<%- end %> + +<%= raw(@markdown_linker.references) %> diff --git a/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js b/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js new file mode 100644 index 00000000000..51f4b36f251 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/adapters/chat-message.js @@ -0,0 +1,22 @@ +import RESTAdapter from "discourse/adapters/rest"; + +export default class ChatMessage extends RESTAdapter { + pathFor(store, type, findArgs) { + if (findArgs.targetMessageId) { + return `/chat/lookup/${findArgs.targetMessageId}.json?chat_channel_id=${findArgs.channelId}`; + } + + let path = `/chat/${findArgs.channelId}/messages.json?page_size=${findArgs.pageSize}`; + if (findArgs.messageId) { + path += `&message_id=${findArgs.messageId}`; + } + if (findArgs.direction) { + path += `&direction=${findArgs.direction}`; + } + return path; + } + + apiNameFor() { + return "chat-message"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js b/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js new file mode 100644 index 00000000000..a1e74d0708c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/admin-chat-route-map.js @@ -0,0 +1,7 @@ +export default { + resource: "admin.adminPlugins", + path: "/plugins", + map() { + this.route("chat"); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/chat-route-map.js b/plugins/chat/assets/javascripts/discourse/chat-route-map.js new file mode 100644 index 00000000000..a4e03558a2d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/chat-route-map.js @@ -0,0 +1,25 @@ +export default function () { + this.route("chat", { path: "/chat" }, function () { + this.route( + "channel", + { path: "/channel/:channelId/:channelTitle" }, + function () { + this.route("info", { path: "/info" }, function () { + this.route("about", { path: "/about" }); + this.route("members", { path: "/members" }); + this.route("settings", { path: "/settings" }); + }); + } + ); + + this.route("draft-channel", { path: "/draft-channel" }); + this.route("browse", { path: "/browse" }, function () { + this.route("all", { path: "/all" }); + this.route("closed", { path: "/closed" }); + this.route("open", { path: "/open" }); + this.route("archived", { path: "/archived" }); + }); + this.route("message", { path: "/message/:messageId" }); + this.route("channelByName", { path: "/chat_channels/:channelName" }); + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/components/channels-list.js b/plugins/chat/assets/javascripts/discourse/components/channels-list.js new file mode 100644 index 00000000000..bd1a8592b8b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/channels-list.js @@ -0,0 +1,129 @@ +import { bind } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { and, empty, reads } from "@ember/object/computed"; +import { DRAFT_CHANNEL_VIEW } from "discourse/plugins/chat/discourse/services/chat"; + +export default class ChannelsList extends Component { + @service chat; + @service router; + tagName = ""; + inSidebar = false; + toggleSection = null; + onSelect = null; + @reads("chat.publicChannels.[]") publicChannels; + @reads("chat.directMessageChannels.[]") directMessageChannels; + @empty("publicChannels") publicChannelsEmpty; + @and("site.mobileView", "showDirectMessageChannels") + showMobileDirectMessageButton; + + @computed("canCreateDirectMessageChannel") + get createDirectMessageChannelLabel() { + if (!this.canCreateDirectMessageChannel) { + return "chat.direct_messages.cannot_create"; + } + + return "chat.direct_messages.new"; + } + + @computed("canCreateDirectMessageChannel", "directMessageChannels") + get showDirectMessageChannels() { + return ( + this.canCreateDirectMessageChannel || + this.directMessageChannels?.length > 0 + ); + } + + get canCreateDirectMessageChannel() { + return this.chat.userCanDirectMessage; + } + + @computed("directMessageChannels.@each.last_message_sent_at") + get sortedDirectMessageChannels() { + if (!this.directMessageChannels?.length) { + return []; + } + + return this.chat.truncateDirectMessageChannels( + this.chat.sortDirectMessageChannels(this.directMessageChannels) + ); + } + + @computed("inSidebar") + get publicChannelClasses() { + return `channels-list-container public-channels ${ + this.inSidebar ? "collapsible-sidebar-section" : "" + }`; + } + + @computed( + "publicChannelsEmpty", + "currentUser.{staff,has_joinable_public_channels}" + ) + get displayPublicChannels() { + if (this.publicChannelsEmpty) { + return ( + this.currentUser?.staff || + this.currentUser?.has_joinable_public_channels + ); + } + + return true; + } + + @computed("inSidebar") + get directMessageChannelClasses() { + return `channels-list-container direct-message-channels ${ + this.inSidebar ? "collapsible-sidebar-section" : "" + }`; + } + + @action + browseChannels() { + this.router.transitionTo("chat.browse"); + return false; + } + + @action + startCreatingDmChannel() { + if ( + this.site.mobileView || + this.router.currentRouteName.startsWith("chat.") + ) { + this.router.transitionTo("chat.draft-channel"); + } else { + this.appEvents.trigger("chat:open-view", DRAFT_CHANNEL_VIEW); + } + } + + @action + toggleChannelSection(section) { + this.toggleSection(section); + } + + didRender() { + this._super(...arguments); + + schedule("afterRender", this._applyScrollPosition); + } + + @action + storeScrollPosition() { + const scroller = document.querySelector(".channels-list"); + if (scroller) { + const scrollTop = scroller.scrollTop || 0; + this.session.set("channels-list-position", scrollTop); + } + } + + @bind + _applyScrollPosition() { + const data = this.session.get("channels-list-position"); + if (data) { + const scroller = document.querySelector(".channels-list"); + scroller.scrollTo(0, data); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js new file mode 100644 index 00000000000..a453ad360fe --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-browse-view.js @@ -0,0 +1,99 @@ +import { INPUT_DELAY } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { inject as service } from "@ember/service"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; +import showModal from "discourse/lib/show-modal"; + +const TABS = ["all", "open", "closed", "archived"]; +const PER_PAGE = 20; + +export default class ChatBrowseView extends Component { + @service router; + @tracked isLoading = false; + @tracked channels = []; + tagName = ""; + + tabs = TABS; + offset = 0; + canLoadMore = true; + + didReceiveAttrs() { + this._super(...arguments); + + this.channels = []; + this.canLoadMore = true; + this.offset = 0; + this.fetchChannels(); + } + + async fetchChannels(params) { + if (this.isLoading || !this.canLoadMore) { + return; + } + + this.isLoading = true; + + try { + const results = await ChatApi.chatChannels({ + limit: PER_PAGE, + offset: this.offset, + status: this.status, + filter: this.filter, + ...params, + }); + + if (results.length) { + this.channels.pushObjects(results); + } + + if (results.length < PER_PAGE) { + this.canLoadMore = false; + } + } finally { + this.offset = this.offset + PER_PAGE; + this.isLoading = false; + } + } + + get chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + } + + @action + onScroll() { + if (this.isLoading) { + return; + } + + discourseDebounce(this, this.fetchChannels, INPUT_DELAY); + } + + @action + debouncedFiltering(event) { + discourseDebounce( + this, + this.filterChannels, + event.target.value, + INPUT_DELAY + ); + } + + @action + createChannel() { + showModal("create-channel"); + } + + @bind + filterChannels(filter) { + this.canLoadMore = true; + this.filter = filter; + this.channels = []; + this.offset = 0; + + this.fetchChannels(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js new file mode 100644 index 00000000000..3b4d038645d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js @@ -0,0 +1,19 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelAboutView extends Component { + @service chat; + tagName = ""; + channel = null; + onEditChatChannelTitle = null; + onEditChatChannelDescription = null; + isLoading = false; + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels().then(() => { + this.chat.openChannel(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js new file mode 100644 index 00000000000..2569653fac3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js @@ -0,0 +1,114 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import discourseLater from "discourse-common/lib/later"; +import { isEmpty } from "@ember/utils"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { + EXISTING_TOPIC_SELECTION, + NEW_TOPIC_SELECTION, +} from "discourse/plugins/chat/discourse/components/chat-to-topic-selector"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { htmlSafe } from "@ember/template"; + +export default Component.extend({ + chat: service(), + tagName: "", + chatChannel: null, + + selection: "newTopic", + newTopic: equal("selection", NEW_TOPIC_SELECTION), + existingTopic: equal("selection", EXISTING_TOPIC_SELECTION), + + saving: false, + + topicTitle: null, + categoryId: null, + tags: null, + selectedTopicId: null, + + @action + archiveChannel() { + this.set("saving", true); + return ajax({ + url: `/chat/chat_channels/${this.chatChannel.id}/archive.json`, + type: "PUT", + data: this._data(), + }) + .then(() => { + this.appEvents.trigger("modal-body:flash", { + text: I18n.t("chat.channel_archive.process_started"), + messageClass: "success", + }); + + this.chatChannel.set("status", CHANNEL_STATUSES.archived); + + discourseLater(() => { + this.closeModal(); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => this.set("saving", false)); + }, + + _data() { + const data = { + type: this.selection, + chat_channel_id: this.chatChannel.id, + }; + if (this.newTopic) { + data.title = this.topicTitle; + data.category_id = this.categoryId; + data.tags = this.tags; + } + if (this.existingTopic) { + data.topic_id = this.selectedTopicId; + } + return data; + }, + + @discourseComputed("saving", "selectedTopicId", "topicTitle", "selection") + buttonDisabled(saving, selectedTopicId, topicTitle) { + if (saving) { + return true; + } + if ( + this.newTopic && + (!topicTitle || + topicTitle.length < this.siteSettings.min_topic_title_length || + topicTitle.length > this.siteSettings.max_topic_title_length) + ) { + return true; + } + + if (this.existingTopic && isEmpty(selectedTopicId)) { + return true; + } + return false; + }, + + @discourseComputed() + instructionLabels() { + const labels = {}; + labels[NEW_TOPIC_SELECTION] = I18n.t( + "chat.selection.new_topic.instructions_channel_archive" + ); + labels[EXISTING_TOPIC_SELECTION] = I18n.t( + "chat.selection.existing_topic.instructions_channel_archive" + ); + return labels; + }, + + @discourseComputed() + instructionsText() { + return htmlSafe( + I18n.t("chat.channel_archive.instructions", { + channelTitle: this.chatChannel.escapedTitle, + }) + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js new file mode 100644 index 00000000000..6f006cddd09 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js @@ -0,0 +1,81 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import I18n from "I18n"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { ajax } from "discourse/lib/ajax"; +import getURL from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Component.extend({ + channel: null, + tagName: "", + + @discourseComputed( + "channel.status", + "channel.archived_messages", + "channel.total_messages", + "channel.archive_failed" + ) + channelArchiveFailedMessage() { + return htmlSafe( + I18n.t("chat.channel_status.archive_failed", { + completed: this.channel.archived_messages, + total: this.channel.total_messages, + topic_url: this._getTopicUrl(), + }) + ); + }, + + @discourseComputed( + "channel.status", + "channel.archived_messages", + "channel.total_messages", + "channel.archive_completed" + ) + channelArchiveCompletedMessage() { + return htmlSafe( + I18n.t("chat.channel_status.archive_completed", { + topic_url: this._getTopicUrl(), + }) + ); + }, + + @action + retryArchive() { + return ajax({ + url: `/chat/chat_channels/${this.channel.id}/retry_archive.json`, + type: "PUT", + }) + .then(() => { + this.channel.set("archive_failed", false); + }) + .catch(popupAjaxError); + }, + + didInsertElement() { + this._super(...arguments); + if (this.currentUser.admin) { + this.messageBus.subscribe("/chat/channel-archive-status", (busData) => { + if (busData.chat_channel_id === this.channel.id) { + this.channel.setProperties({ + archive_failed: busData.archive_failed, + archive_completed: busData.archive_completed, + archived_messages: busData.archived_messages, + archive_topic_id: busData.archive_topic_id, + total_messages: busData.total_messages, + }); + } + }); + } + }, + + willDestroyElement() { + this._super(...arguments); + this.messageBus.unsubscribe("/chat/channel-archive-status"); + }, + + _getTopicUrl() { + return getURL(`/t/-/${this.channel.archive_topic_id}`); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js new file mode 100644 index 00000000000..3392fe9f059 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-card.js @@ -0,0 +1,13 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelCard extends Component { + @service chat; + tagName = ""; + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js new file mode 100644 index 00000000000..68622e7560a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-header.js @@ -0,0 +1,3 @@ +import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; + +export default ComboBoxSelectBoxHeaderComponent.extend({}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js new file mode 100644 index 00000000000..50e8d0b3194 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser-row.js @@ -0,0 +1,5 @@ +import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; + +export default SelectKitRowComponent.extend({ + classNames: ["chat-channel-chooser-row"], +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js new file mode 100644 index 00000000000..94ab9da6d84 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-chooser.js @@ -0,0 +1,14 @@ +import ComboBoxComponent from "select-kit/components/combo-box"; + +export default ComboBoxComponent.extend({ + pluginApiIdentifiers: ["chat-channel-chooser"], + classNames: ["chat-channel-chooser"], + + selectKitOptions: { + headerComponent: "chat-channel-chooser-header", + }, + + modifyComponentForRow() { + return "chat-channel-chooser-row"; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js new file mode 100644 index 00000000000..3f38523f186 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js @@ -0,0 +1,68 @@ +import Component from "@ember/component"; +import { isEmpty } from "@ember/utils"; +import I18n from "I18n"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseLater from "discourse-common/lib/later"; +import { htmlSafe } from "@ember/template"; + +export default Component.extend({ + chat: service(), + router: service(), + tagName: "", + chatChannel: null, + channelNameConfirmation: null, + deleting: false, + confirmed: false, + + @discourseComputed("deleting", "channelNameConfirmation", "confirmed") + buttonDisabled(deleting, channelNameConfirmation, confirmed) { + if (deleting || confirmed) { + return true; + } + + if ( + isEmpty(channelNameConfirmation) || + channelNameConfirmation.toLowerCase() !== + this.chatChannel.title.toLowerCase() + ) { + return true; + } + return false; + }, + + @action + deleteChannel() { + this.set("deleting", true); + return ajax(`/chat/chat_channels/${this.chatChannel.id}.json`, { + method: "DELETE", + data: { channel_name_confirmation: this.channelNameConfirmation }, + }) + .then(() => { + this.set("confirmed", true); + this.appEvents.trigger("modal-body:flash", { + text: I18n.t("chat.channel_delete.process_started"), + messageClass: "success", + }); + + discourseLater(() => { + this.closeModal(); + this.router.transitionTo("chat"); + }, 3000); + }) + .catch(popupAjaxError) + .finally(() => this.set("deleting", false)); + }, + + @discourseComputed() + instructionsText() { + return htmlSafe( + I18n.t("chat.channel_delete.instructions", { + name: this.chatChannel.escapedTitle, + }) + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js new file mode 100644 index 00000000000..3347b5e2369 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-leave-btn.js @@ -0,0 +1,25 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { equal } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + chat: service(), + + isDirectMessageRow: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + + @discourseComputed("isDirectMessageRow") + leaveChatTitleKey(isDirectMessageRow) { + if (isDirectMessageRow) { + return "chat.direct_messages.leave"; + } else { + return "chat.leave"; + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js new file mode 100644 index 00000000000..49907dbd68c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-members-view.js @@ -0,0 +1,113 @@ +import { isEmpty } from "@ember/utils"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseDebounce from "discourse-common/lib/debounce"; + +const LIMIT = 50; + +export default class ChatChannelMembersView extends Component { + tagName = ""; + channel = null; + members = null; + isSearchFocused = false; + isFetchingMembers = false; + onlineUsers = null; + offset = 0; + filter = null; + inputSelector = "channel-members-view__search-input"; + canLoadMore = true; + + didInsertElement() { + this._super(...arguments); + + if (!this.channel || this.channel.isDraft) { + return; + } + + this._focusSearch(); + this.set("members", []); + this.fetchMembers(); + + this.appEvents.on("chat:refresh-channel-members", this, "onFilterMembers"); + } + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat:refresh-channel-members", this, "onFilterMembers"); + } + + get chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + } + + @action + onFilterMembers(username) { + this.set("filter", username); + this.set("offset", 0); + this.set("canLoadMore", true); + + discourseDebounce( + this, + this.fetchMembers, + this.filter, + this.offset, + INPUT_DELAY + ); + } + + @action + loadMore() { + if (!this.canLoadMore) { + return; + } + + discourseDebounce( + this, + this.fetchMembers, + this.filter, + this.offset, + INPUT_DELAY + ); + } + + fetchMembersHandler(id, params = {}) { + return ChatApi.chatChannelMemberships(id, params); + } + + fetchMembers(filter = null, offset = 0) { + this.set("isFetchingMembers", true); + + return this.fetchMembersHandler(this.channel.id, { + username: filter, + offset, + }) + .then((response) => { + if (this.offset === 0) { + this.set("members", []); + } + + if (isEmpty(response)) { + this.set("canLoadMore", false); + } else { + this.set("offset", this.offset + LIMIT); + this.members.pushObjects(response); + } + }) + .finally(() => { + this.set("isFetchingMembers", false); + }); + } + + _focusSearch() { + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + schedule("afterRender", () => { + document.getElementsByClassName(this.inputSelector)[0]?.focus(); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js new file mode 100644 index 00000000000..23be8f9a672 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-preview-card.js @@ -0,0 +1,26 @@ +import Component from "@ember/component"; +import { isEmpty } from "@ember/utils"; +import { action, computed } from "@ember/object"; +import { readOnly } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelPreviewCard extends Component { + @service chat; + tagName = ""; + + channel = null; + + @readOnly("channel.isOpen") showJoinButton; + + @computed("channel.description") + get hasDescription() { + return !isEmpty(this.channel.description); + } + + @action + afterMembershipToggle() { + this.chat.forceRefreshChannels().then(() => { + this.chat.openChannel(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js new file mode 100644 index 00000000000..bc8b6ad4216 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-row.js @@ -0,0 +1,132 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import discourseComputed from "discourse-common/utils/decorators"; +import getURL from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + router: service(), + chat: service(), + channel: null, + switchChannel: null, + isDirectMessageRow: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + options: null, + + didInsertElement() { + this._super(...arguments); + + if (this.isDirectMessageRow) { + this.channel.chatable.users[0].trackStatus(); + } + }, + + willDestroyElement() { + this._super(...arguments); + + if (this.isDirectMessageRow) { + this.channel.chatable.users[0].stopTrackingStatus(); + } + }, + + @discourseComputed( + "channel.id", + "chat.activeChannel.id", + "router.currentRouteName" + ) + active(channelId, activeChannelId, currentRouteName) { + return ( + currentRouteName?.startsWith("chat.channel") && + channelId === activeChannelId + ); + }, + + @discourseComputed("active", "channel.{id,muted}", "channel.focused") + rowClassNames(active, channel, focused) { + const classes = ["chat-channel-row", `chat-channel-${channel.id}`]; + if (active) { + classes.push("active"); + } + if (focused) { + classes.push("focused"); + } + if (channel.current_user_membership.muted) { + classes.push("muted"); + } + return classes.join(" "); + }, + + @discourseComputed( + "isDirectMessageRow", + "channel.chatable.users.[]", + "channel.chatable.users.@each.status" + ) + showUserStatus(isDirectMessageRow) { + return !!( + isDirectMessageRow && + this.channel.chatable.users.length === 1 && + this.channel.chatable.users[0].status + ); + }, + + @action + handleNewWindow(event) { + // Middle mouse click + if (event.which === 2) { + window + .open( + getURL(`/chat/channel/${this.channel.id}/${this.channel.title}`), + "_blank" + ) + .focus(); + } + }, + + @action + handleSwitchChannel(event) { + if (this.switchChannel) { + this.switchChannel(this.channel); + event.preventDefault(); + } + }, + + @action + handleClick(event) { + if (event.target.classList.contains("chat-channel-leave-btn")) { + return true; + } + + if ( + event.target.classList.contains("chat-channel-settings-btn") || + event.target.parentElement.classList.contains("select-kit-header-wrapper") + ) { + return; + } + + this.handleSwitchChannel(event); + }, + + @action + handleKeyUp(event) { + if (event.key !== "Enter") { + return; + } + + this.handleSwitchChannel(event); + }, + + @discourseComputed("channel.chatable_type") + leaveChatTitle() { + if (this.channel.isDirectMessageChannel) { + return I18n.t("chat.direct_messages.leave"); + } else { + return I18n.t("chat.channel_settings.leave_channel"); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js new file mode 100644 index 00000000000..07d4e9b6c53 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selection-row.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + @discourseComputed("model", "model.focused") + rowClassNames(model, focused) { + return `chat-channel-selection-row ${focused ? "focused" : ""} ${ + this.model.user ? "user-row" : "channel-row" + }`; + }, + + @action + handleClick(event) { + if (this.onClick) { + this.onClick(this.model); + event.preventDefault(); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js new file mode 100644 index 00000000000..bfd46d64c0e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-selector-modal-inner.js @@ -0,0 +1,181 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { ajax } from "discourse/lib/ajax"; +import { bind } from "discourse-common/utils/decorators"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { isPresent } from "@ember/utils"; + +export default Component.extend({ + chat: service(), + tagName: "", + filter: "", + channels: null, + searchIndex: 0, + loading: false, + + init() { + this._super(...arguments); + this.appEvents.on("chat-channel-selector-modal:close", this.close); + this.getInitialChannels(); + }, + + didInsertElement() { + this._super(...arguments); + document.addEventListener("keyup", this.onKeyUp); + document + .getElementById("chat-channel-selector-modal-inner") + ?.addEventListener("mouseover", this.mouseover); + document.getElementById("chat-channel-selector-input")?.focus(); + }, + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat-channel-selector-modal:close", this.close); + document.removeEventListener("keyup", this.onKeyUp); + document + .getElementById("chat-channel-selector-modal-inner") + ?.removeEventListener("mouseover", this.mouseover); + }, + + @bind + mouseover(e) { + if (e.target.classList.contains("chat-channel-selection-row")) { + let channel; + const id = parseInt(e.target.dataset.id, 10); + if (e.target.classList.contains("channel-row")) { + channel = this.channels.findBy("id", id); + } else { + channel = this.channels.find((c) => c.user && c.id === id); + } + channel?.set("focused", true); + this.channels.forEach((c) => { + if (c !== channel) { + c.set("focused", false); + } + }); + } + }, + + @bind + onKeyUp(e) { + if (e.key === "Enter") { + let focusedChannel = this.channels.find((c) => c.focused); + this.switchChannel(focusedChannel); + e.preventDefault(); + } else if (e.key === "ArrowDown") { + this.arrowNavigateChannels("down"); + e.preventDefault(); + } else if (e.key === "ArrowUp") { + this.arrowNavigateChannels("up"); + e.preventDefault(); + } + }, + + arrowNavigateChannels(direction) { + const indexOfFocused = this.channels.findIndex((c) => c.focused); + if (indexOfFocused > -1) { + const nextIndex = direction === "down" ? 1 : -1; + const nextChannel = this.channels[indexOfFocused + nextIndex]; + if (nextChannel) { + this.channels[indexOfFocused].set("focused", false); + nextChannel.set("focused", true); + } + } else { + this.channels[0].set("focused", true); + } + + schedule("afterRender", () => { + let focusedChannel = document.querySelector( + "#chat-channel-selector-modal-inner .chat-channel-selection-row.focused" + ); + focusedChannel?.scrollIntoView({ block: "nearest", inline: "start" }); + }); + }, + + @action + switchChannel(channel) { + if (channel.user) { + return this.fetchOrCreateChannelForUser(channel).then((response) => { + this.chat + .startTrackingChannel(ChatChannel.create(response.chat_channel)) + .then((newlyTracked) => { + this.chat.openChannel(newlyTracked); + this.close(); + }); + }); + } else { + this.chat.openChannel(channel); + this.close(); + } + }, + + @action + search(value) { + if (isPresent(value?.trim())) { + discourseDebounce( + this, + this.fetchChannelsFromServer, + value?.trim(), + INPUT_DELAY + ); + } else { + discourseDebounce(this, this.getInitialChannels, INPUT_DELAY); + } + }, + + @action + fetchChannelsFromServer(filter) { + this.setProperties({ + loading: true, + searchIndex: this.searchIndex + 1, + }); + const thisSearchIndex = this.searchIndex; + ajax("/chat/chat_channels/search", { data: { filter } }) + .then((searchModel) => { + if (this.searchIndex === thisSearchIndex) { + this.set("searchModel", searchModel); + const channels = searchModel.public_channels.concat( + searchModel.direct_message_channels, + searchModel.users + ); + channels.forEach((c) => { + if (c.username) { + c.user = true; // This is used by the `chat-channel-selection-row` component + } + }); + this.setProperties({ + channels: channels.map((channel) => ChatChannel.create(channel)), + loading: false, + }); + this.focusFirstChannel(this.channels); + } + }) + .catch(popupAjaxError); + }, + + @action + getInitialChannels() { + return this.chat.getChannelsWithFilter(this.filter).then((channels) => { + this.focusFirstChannel(channels); + this.set("channels", channels); + }); + }, + + @action + fetchOrCreateChannelForUser(user) { + return ajax("/chat/direct_messages/create.json", { + method: "POST", + data: { usernames: [user.username] }, + }).catch(popupAjaxError); + }, + + focusFirstChannel(channels) { + channels.forEach((c) => c.set("focused", false)); + channels[0]?.set("focused", true); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js new file mode 100644 index 00000000000..9918af7a344 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-row.js @@ -0,0 +1,40 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +const NOTIFICATION_LEVELS = [ + { name: I18n.t("chat.notification_levels.never"), value: "never" }, + { name: I18n.t("chat.notification_levels.mention"), value: "mention" }, + { name: I18n.t("chat.notification_levels.always"), value: "always" }, +]; + +const MUTED_OPTIONS = [ + { name: I18n.t("chat.settings.muted_on"), value: true }, + { name: I18n.t("chat.settings.muted_off"), value: false }, +]; + +export default Component.extend({ + channel: null, + loading: false, + showSaveSuccess: false, + notificationLevels: NOTIFICATION_LEVELS, + mutedOptions: MUTED_OPTIONS, + chat: service(), + router: service(), + + didInsertElement() { + this._super(...arguments); + }, + + @discourseComputed("channel.chatable_type") + chatChannelClass(channelType) { + return `${channelType.toLowerCase()}-chat-channel`; + }, + + @action + previewChannel() { + this.chat.openChannel(this.channel); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js new file mode 100644 index 00000000000..8ca9eda17af --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js @@ -0,0 +1,135 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import showModal from "discourse/lib/show-modal"; +import I18n from "I18n"; +import { camelize } from "@ember/string"; +import discourseLater from "discourse-common/lib/later"; + +const NOTIFICATION_LEVELS = [ + { name: I18n.t("chat.notification_levels.never"), value: "never" }, + { name: I18n.t("chat.notification_levels.mention"), value: "mention" }, + { name: I18n.t("chat.notification_levels.always"), value: "always" }, +]; + +const MUTED_OPTIONS = [ + { name: I18n.t("chat.settings.muted_on"), value: true }, + { name: I18n.t("chat.settings.muted_off"), value: false }, +]; + +export default class ChatChannelSettingsView extends Component { + @service chat; + @service router; + @service dialog; + tagName = ""; + channel = null; + + notificationLevels = NOTIFICATION_LEVELS; + mutedOptions = MUTED_OPTIONS; + isSavingNotificationSetting = false; + savedDesktopNotificationLevel = false; + savedMobileNotificationLevel = false; + savedMuted = false; + + _updateAutoJoinUsers(value) { + return ChatApi.modifyChatChannel(this.channel.id, { + auto_join_users: value, + }) + .then((chatChannel) => { + this.channel.set("auto_join_users", chatChannel.auto_join_users); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + saveNotificationSettings(key, value) { + if (this.channel[key] === value) { + return; + } + + const camelizedKey = camelize(`saved_${key}`); + this.set(camelizedKey, false); + + const settings = {}; + settings[key] = value; + return ChatApi.updateChatChannelNotificationsSettings( + this.channel.id, + settings + ) + .then((membership) => { + this.channel.current_user_membership.setProperties({ + muted: membership.muted, + desktop_notification_level: membership.desktop_notification_level, + mobile_notification_level: membership.mobile_notification_level, + }); + this.set(camelizedKey, true); + }) + .finally(() => { + discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set(camelizedKey, false); + }, 2000); + }); + } + + @computed( + "siteSettings.chat_allow_archiving_channels", + "channel.{isArchived,isReadOnly}" + ) + get canArchiveChannel() { + return ( + this.siteSettings.chat_allow_archiving_channels && + !this.channel.isArchived && + !this.channel.isReadOnly + ); + } + + @computed("channel.isCategoryChannel") + get autoJoinAvailable() { + return ( + this.siteSettings.max_chat_auto_joined_users > 0 && + this.channel.isCategoryChannel + ); + } + + @action + onArchiveChannel() { + const controller = showModal("chat-channel-archive-modal"); + controller.set("chatChannel", this.channel); + } + + @action + onDeleteChannel() { + const controller = showModal("chat-channel-delete-modal"); + controller.set("chatChannel", this.channel); + } + + @action + onToggleChannelState() { + const controller = showModal("chat-channel-toggle"); + controller.set("chatChannel", this.channel); + } + + @action + onDisableAutoJoinUsers() { + this._updateAutoJoinUsers(false); + } + + @action + onEnableAutoJoinUsers() { + this.dialog.confirm({ + message: I18n.t("chat.settings.auto_join_users_warning", { + category: this.channel.chatable.name, + }), + didConfirm: () => this._updateAutoJoinUsers(true), + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js new file mode 100644 index 00000000000..f6048b21b9d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-status.js @@ -0,0 +1,57 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import Component from "@ember/component"; +import { + CHANNEL_STATUSES, + channelStatusIcon, + channelStatusName, +} from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + format: null, + + init() { + this._super(...arguments); + if (!["short", "long"].includes(this.format)) { + this.set("format", "long"); + } + }, + + @discourseComputed("channel.status") + channelStatusMessage(channelStatus) { + if (channelStatus === CHANNEL_STATUSES.open) { + return null; + } + + if (this.format === "long") { + return this._longStatusMessage(channelStatus); + } else { + return this._shortStatusMessage(channelStatus); + } + }, + + @discourseComputed("channel.status") + channelStatusIcon(channelStatus) { + return channelStatusIcon(channelStatus); + }, + + _shortStatusMessage(channelStatus) { + return channelStatusName(channelStatus); + }, + + _longStatusMessage(channelStatus) { + switch (channelStatus) { + case CHANNEL_STATUSES.closed: + return I18n.t("chat.channel_status.closed_header"); + break; + case CHANNEL_STATUSES.readOnly: + return I18n.t("chat.channel_status.read_only_header"); + break; + case CHANNEL_STATUSES.archived: + return I18n.t("chat.channel_status.archived_header"); + break; + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js new file mode 100644 index 00000000000..fc261ddcfac --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-title.js @@ -0,0 +1,23 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { computed } from "@ember/object"; +import { gt, reads } from "@ember/object/computed"; + +export default class ChatChannelTitle extends Component { + tagName = ""; + channel = null; + unreadIndicator = false; + + @reads("channel.chatable.users.[]") users; + @gt("users.length", 1) multiDm; + + @computed("users") + get usernames() { + return this.users.mapBy("username").join(", "); + } + + @computed("channel.chatable.color") + get channelColorStyle() { + return htmlSafe(`color: #${this.channel.chatable.color}`); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js new file mode 100644 index 00000000000..87c0cfd9b21 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-toggle-view.js @@ -0,0 +1,62 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import I18n from "I18n"; +import { action, computed } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default class ChatChannelToggleView extends Component { + @service chat; + @service router; + tagName = ""; + channel = null; + onStatusChange = null; + + @computed("channel.isClosed") + get buttonLabel() { + if (this.channel.isClosed) { + return "chat.channel_settings.open_channel"; + } else { + return "chat.channel_settings.close_channel"; + } + } + + @computed("channel.isClosed") + get instructions() { + if (this.channel.isClosed) { + return htmlSafe(I18n.t("chat.channel_open.instructions")); + } else { + return htmlSafe(I18n.t("chat.channel_close.instructions")); + } + } + + @computed("channel.isClosed") + get modalTitle() { + if (this.channel.isClosed) { + return "chat.channel_open.title"; + } else { + return "chat.channel_close.title"; + } + } + + @action + changeChannelStatus() { + const status = this.channel.isClosed + ? CHANNEL_STATUSES.open + : CHANNEL_STATUSES.closed; + + return ajax(`/chat/chat_channels/${this.channel.id}/change_status.json`, { + method: "PUT", + data: { status }, + }) + .then(() => { + this.channel.set("status", status); + }) + .catch(popupAjaxError) + .finally(() => { + this.onStatusChange?.(this.channel); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js new file mode 100644 index 00000000000..f24cd64a31b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-unread-indicator.js @@ -0,0 +1,46 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { equal, gt } from "@ember/object/computed"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + channel: null, + + isDirectMessage: equal( + "channel.chatable_type", + CHATABLE_TYPES.directMessageChannel + ), + + hasUnread: gt("unreadCount", 0), + + @discourseComputed( + "currentUser.chat_channel_tracking_state.@each.{unread_count,unread_mentions}", + "channel.id" + ) + channelTrackingState(state, channelId) { + return state?.[channelId]; + }, + + @discourseComputed( + "channelTrackingState.unread_mentions", + "channel", + "isDirectMessage" + ) + isUrgent(unreadMentions, channel, isDirectMessage) { + if (!channel) { + return; + } + + return isDirectMessage || unreadMentions > 0; + }, + + @discourseComputed("channelTrackingState.unread_count", "channel") + unreadCount(unreadCount, channel) { + if (!channel) { + return; + } + + return unreadCount || 0; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js new file mode 100644 index 00000000000..36dad78ae34 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js @@ -0,0 +1,7 @@ +import Component from "@ember/component"; + +export default class ChatComposerDropdown extends Component { + tagName = ""; + buttons = null; + isDisabled = false; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js new file mode 100644 index 00000000000..88361c29398 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-inline-buttons.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default class ChatComposerInlineButtons extends Component { + tagName = ""; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js new file mode 100644 index 00000000000..44494409ab5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default Component.extend({ + tagName: "", +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js new file mode 100644 index 00000000000..a2a89a2119c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js @@ -0,0 +1,25 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import { isImage } from "discourse/lib/uploads"; + +export default Component.extend({ + IMAGE_TYPE: "image", + + tagName: "", + classNames: "chat-upload", + isDone: false, + upload: null, + onCancel: null, + + @discourseComputed("upload.{original_filename,fileName}") + type(upload) { + if (isImage(upload.original_filename || upload.fileName)) { + return this.IMAGE_TYPE; + } + }, + + @discourseComputed("isDone", "upload.{original_filename,fileName}") + fileName(isDone, upload) { + return isDone ? upload.original_filename : upload.fileName; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js new file mode 100644 index 00000000000..f4b6877bf5c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js @@ -0,0 +1,132 @@ +import Component from "@ember/component"; +import { clipboardHelpers } from "discourse/lib/utilities"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; +import UppyUploadMixin from "discourse/mixins/uppy-upload"; + +export default Component.extend(UppyUploadMixin, { + classNames: ["chat-composer-uploads"], + mediaOptimizationWorker: service(), + id: "chat-composer-uploader", + type: "chat-composer", + uploads: null, + useMultipartUploadsIfAvailable: true, + fullPage: false, + + init() { + this._super(...arguments); + this.setProperties({ + uploads: [], + fileInputSelector: `#${this.fileUploadElementId}`, + }); + this.appEvents.on("chat-composer:load-uploads", this, "_loadUploads"); + }, + + didInsertElement() { + this._super(...arguments); + this.composerInputEl = document.querySelector(".chat-composer-input"); + this.composerInputEl?.addEventListener("paste", this._pasteEventListener); + }, + + willDestroyElement() { + this._super(...arguments); + this.appEvents.off("chat-composer:load-uploads", this, "_loadUploads"); + this.composerInputEl?.removeEventListener( + "paste", + this._pasteEventListener + ); + }, + + uploadDone(upload) { + this.uploads.pushObject(upload); + this.onUploadChanged(this.uploads); + }, + + @discourseComputed("uploads.length", "inProgressUploads.length") + showUploadsContainer(uploadsCount, inProgressUploadsCount) { + return uploadsCount > 0 || inProgressUploadsCount > 0; + }, + + @action + cancelUploading(upload) { + this.appEvents.trigger(`upload-mixin:${this.id}:cancel-upload`, { + fileId: upload.id, + }); + this.uploads.removeObject(upload); + this.onUploadChanged(this.uploads); + }, + + @action + removeUpload(upload) { + this.uploads.removeObject(upload); + this.onUploadChanged(this.uploads); + }, + + _uploadDropTargetOptions() { + let targetEl; + if (this.fullPage) { + targetEl = document.querySelector(".full-page-chat"); + } else { + targetEl = document.querySelector( + ".topic-chat-container.expanded.visible" + ); + } + + if (!targetEl) { + return this._super(); + } + + return { + target: targetEl, + }; + }, + + _loadUploads(uploads) { + this._uppyInstance?.cancelAll(); + this.set("uploads", uploads); + }, + + _uppyReady() { + if (this.siteSettings.composer_media_optimization_image_enabled) { + this._useUploadPlugin(UppyMediaOptimization, { + optimizeFn: (data, opts) => + this.mediaOptimizationWorker.optimizeImage(data, opts), + runParallel: !this.site.isMobileDevice, + }); + } + + this._onPreProcessProgress((file) => { + const inProgressUpload = this.inProgressUploads.findBy("id", file.id); + if (!inProgressUpload?.processing) { + inProgressUpload?.set("processing", true); + } + }); + + this._onPreProcessComplete((file) => { + const inProgressUpload = this.inProgressUploads.findBy("id", file.id); + inProgressUpload?.set("processing", false); + }); + }, + + @bind + _pasteEventListener(event) { + if (document.activeElement !== this.composerInputEl) { + return; + } + + const { canUpload, canPasteHtml, types } = clipboardHelpers(event, { + siteSettings: this.siteSettings, + canUpload: true, + }); + + if (!canUpload || canPasteHtml || types.includes("text/plain")) { + return; + } + + if (event && event.clipboardData && event.clipboardData.files) { + this._addFiles([...event.clipboardData.files], { pasted: true }); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js new file mode 100644 index 00000000000..ad4f55e8d1e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -0,0 +1,732 @@ +import { isEmpty } from "@ember/utils"; +import Component from "@ember/component"; +import showModal from "discourse/lib/show-modal"; +import discourseComputed, { + afterRender, + bind, +} from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; +import userSearch from "discourse/lib/user-search"; +import { action } from "@ember/object"; +import { cancel, next, schedule, throttle } from "@ember/runloop"; +import { cloneJSON } from "discourse-common/lib/object"; +import { findRawTemplate } from "discourse-common/lib/raw-templates"; +import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; +import { emojiUrlFor } from "discourse/lib/text"; +import { inject as service } from "@ember/service"; +import { readOnly, reads } from "@ember/object/computed"; +import { SKIP } from "discourse/lib/autocomplete"; +import { Promise } from "rsvp"; +import { translations } from "pretty-text/emoji/data"; +import { channelStatusName } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; +import { + chatComposerButtons, + chatComposerButtonsDependentKeys, +} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +const THROTTLE_MS = 150; + +export default Component.extend(TextareaTextManipulation, { + chatChannel: null, + lastChatChannelId: null, + chat: service(), + classNames: ["chat-composer-container"], + classNameBindings: ["emojiPickerVisible:with-emoji-picker"], + userSilenced: readOnly("details.user_silenced"), + chatEmojiReactionStore: service("chat-emoji-reaction-store"), + chatEmojiPickerManager: service("chat-emoji-picker-manager"), + editingMessage: null, + fullPage: false, + onValueChange: null, + timer: null, + value: "", + inProgressUploads: null, + composerEventPrefix: "chat", + composerFocusSelector: ".chat-composer-input", + canAttachUploads: reads("siteSettings.chat_allow_uploads"), + isNetworkUnreliable: reads("chat.isNetworkUnreliable"), + + @discourseComputed(...chatComposerButtonsDependentKeys()) + inlineButtons() { + return chatComposerButtons(this, "inline"); + }, + + @discourseComputed(...chatComposerButtonsDependentKeys()) + dropdownButtons() { + return chatComposerButtons(this, "dropdown"); + }, + + @discourseComputed("chatEmojiPickerManager.{opened,context}") + emojiPickerVisible(picker) { + return picker.opened && picker.context === "chat-composer"; + }, + + @discourseComputed("fullPage") + fileUploadElementId(fullPage) { + return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader"; + }, + + init() { + this._super(...arguments); + + this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged"); + this.appEvents.on( + "upload-mixin:chat-composer-uploader:in-progress-uploads", + this, + "_inProgressUploadsChanged" + ); + + this.setProperties({ + inProgressUploads: [], + _uploads: [], + }); + }, + + didInsertElement() { + this._super(...arguments); + + this._textarea = this.element.querySelector(".chat-composer-input"); + this._$textarea = $(this._textarea); + this._applyCategoryHashtagAutocomplete(this._$textarea); + this._applyEmojiAutocomplete(this._$textarea); + this.appEvents.on("chat:focus-composer", this, "_focusTextArea"); + this.appEvents.on("chat:insert-text", this, "insertText"); + this._focusTextArea(); + + this.appEvents.on("chat:modify-selection", this, "_modifySelection"); + this.appEvents.on( + "chat:open-insert-link-modal", + this, + "_openInsertLinkModal" + ); + document.addEventListener("visibilitychange", this._blurInput); + document.addEventListener("resume", this._blurInput); + document.addEventListener("freeze", this._blurInput); + + this.set("ready", true); + }, + + _modifySelection(opts = { type: null }) { + const sel = this.getSelected("", { lineVal: true }); + if (opts.type === "bold") { + this.applySurround(sel, "**", "**", "bold_text"); + } else if (opts.type === "italic") { + this.applySurround(sel, "_", "_", "italic_text"); + } else if (opts.type === "code") { + this.applySurround(sel, "`", "`", "code_text"); + } + }, + + _openInsertLinkModal() { + const selected = this.getSelected("", { lineVal: true }); + const linkText = selected?.value; + showModal("insert-hyperlink").setProperties({ + linkText, + toolbarEvent: { + addText: (text) => this.addText(selected, text), + }, + }); + }, + + willDestroyElement() { + this._super(...arguments); + + this.appEvents.off( + "chat-composer:reply-to-set", + this, + "_replyToMsgChanged" + ); + this.appEvents.off( + "upload-mixin:chat-composer-uploader:in-progress-uploads", + this, + "_inProgressUploadsChanged" + ); + + if (this.timer) { + cancel(this.timer); + this.timer = null; + } + + this.appEvents.off("chat:focus-composer", this, "_focusTextArea"); + this.appEvents.off("chat:insert-text", this, "insertText"); + this.appEvents.off("chat:modify-selection", this, "_modifySelection"); + this.appEvents.off( + "chat:open-insert-link-modal", + this, + "_openInsertLinkModal" + ); + document.removeEventListener("visibilitychange", this._blurInput); + document.removeEventListener("resume", this._blurInput); + document.removeEventListener("freeze", this._blurInput); + }, + + // It is important that this is keyDown and not keyUp, otherwise + // we add new lines to chat message on send and on edit, because + // you cannot prevent default with a keyUp event -- it is like trying + // to shut the gate after the horse has already bolted! + keyDown(event) { + if (this.site.mobileView || event.altKey || event.metaKey) { + return; + } + + // keyCode for 'Enter' + if (event.keyCode === 13) { + if (event.shiftKey) { + // Shift+Enter: insert newline + return; + } + + // Ctrl+Enter, plain Enter: send + if (!event.ctrlKey) { + // if we are inside a code block just insert newline + const { pre } = this.getSelected(null, { lineVal: true }); + if (this.isInside(pre, /(^|\n)```/g)) { + return; + } + } + + this.sendClicked(); + return false; + } + + if ( + event.key === "ArrowUp" && + this._messageIsEmpty() && + !this.editingMessage + ) { + event.preventDefault(); + this.onEditLastMessageRequested(); + } + + if (event.keyCode === 27) { + // keyCode for 'Escape' + if (this.replyToMsg) { + this.set("value", ""); + this._replyToMsgChanged(null); + return false; + } else if (this.editingMessage) { + this.set("value", ""); + this.cancelEditing(); + return false; + } else { + this._textarea.blur(); + } + } + }, + + didReceiveAttrs() { + this._super(...arguments); + + if ( + !this.editingMessage && + this.draft && + this.chatChannel?.canModifyMessages(this.currentUser) + ) { + // uses uploads from draft here... + this.setProperties({ + value: this.draft.value, + replyToMsg: this.draft.replyToMsg, + }); + + this._syncUploads(this.draft.uploads); + this.setInReplyToMsg(this.draft.replyToMsg); + } + + if (this.editingMessage && !this.loading) { + this.setProperties({ + replyToMsg: null, + value: this.editingMessage.message, + }); + + this._syncUploads(this.editingMessage.uploads); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); + } + + this.set("lastChatChannelId", this.chatChannel.id); + this.resizeTextarea(); + }, + + // the chat-composer needs to be able to set the internal list of uploads + // for chat-composer-uploads to preload in existing uploads for drafts + // and for when messages are being edited. + // + // the opposite is true as well -- when an upload is completed the chat-composer + // needs its internal state updated so drafts can be saved, which is handled + // by the uploadsChanged action + _syncUploads(newUploads = []) { + const currentUploadIds = this._uploads.mapBy("id"); + const newUploadIds = newUploads.mapBy("id"); + + // don't need to load the uploads into chat-composer-uploads if + // nothing has changed otherwise we would rerender for no reason + if ( + currentUploadIds.length === newUploadIds.length && + newUploadIds.every((newUploadId) => + currentUploadIds.includes(newUploadId) + ) + ) { + return; + } + + this.set("_uploads", cloneJSON(newUploads)); + this.appEvents.trigger("chat-composer:load-uploads", this._uploads); + }, + + _inProgressUploadsChanged(inProgressUploads) { + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("inProgressUploads", inProgressUploads); + }); + }, + + _replyToMsgChanged(replyToMsg) { + this.set("replyToMsg", replyToMsg); + this.onValueChange?.(this.value, this._uploads, replyToMsg); + }, + + @action + onTextareaInput(value) { + this.set("value", value); + this.resizeTextarea(); + + // throttle, not debounce, because we do eventually want to react during the typing + this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS); + }, + + @bind + _handleTextareaInput() { + this._applyUserAutocomplete(); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @bind + _blurInput() { + document.activeElement?.blur(); + }, + + @action + uploadClicked() { + this.element.querySelector(`#${this.fileUploadElementId}`).click(); + }, + + @bind + didSelectEmoji(emoji) { + const code = `:${emoji}:`; + this.chatEmojiReactionStore.track(code); + this.addText(this.getSelected(), code); + }, + + @action + insertDiscourseLocalDate() { + showModal("discourse-local-dates-create-modal").setProperties({ + insertDate: (markup) => { + this.addText(this.getSelected(), markup); + }, + }); + }, + + // text-area-manipulation mixin override + addText() { + this._super(...arguments); + + this.resizeTextarea(); + }, + + _applyUserAutocomplete() { + if (this.siteSettings.enable_mentions) { + $(this._textarea).autocomplete({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: true, + transformComplete: (v) => v.username || v.name, + dataSource: (term) => userSearch({ term, includeGroups: true }), + afterComplete: (text) => { + this.set("value", text); + this._focusTextArea(); + }, + }); + } + }, + + _applyCategoryHashtagAutocomplete($textarea) { + setupHashtagAutocomplete( + "chat-composer", + $textarea, + this.siteSettings, + (value) => { + this.set("value", value); + return this._focusTextArea(); + } + ); + }, + + _applyEmojiAutocomplete($textarea) { + if (!this.siteSettings.enable_emoji) { + return; + } + + $textarea.autocomplete({ + template: findRawTemplate("emoji-selector-autocomplete"), + key: ":", + afterComplete: (text) => { + this.set("value", text); + this._focusTextArea(); + }, + treatAsTextarea: true, + + onKeyUp: (text, cp) => { + const matches = + /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( + text.substring(0, cp) + ); + + if (matches && matches[1]) { + return [matches[1]]; + } + }, + + transformComplete: (v) => { + if (v.code) { + this.chatEmojiReactionStore.track(v.code); + return `${v.code}:`; + } else { + $textarea.autocomplete({ cancel: true }); + this.set("emojiPickerIsActive", true); + return ""; + } + }, + + dataSource: (term) => { + return new Promise((resolve) => { + const full = `:${term}`; + term = term.toLowerCase(); + + // We need to avoid quick emoji autocomplete cause it can interfere with quick + // typing, set minimal length to 2 + let minLength = Math.max( + this.siteSettings.emoji_autocomplete_min_chars, + 2 + ); + + if (term.length < minLength) { + return resolve(SKIP); + } + + // bypass :-p and other common typed smileys + if ( + !term.match( + /[^-\{\}\[\]\(\)\*_\<\>\\\/].*[^-\{\}\[\]\(\)\*_\<\>\\\/]/ + ) + ) { + return resolve(SKIP); + } + + if (term === "") { + if (this.chatEmojiReactionStore.favorites.length) { + return resolve(this.chatEmojiReactionStore.favorites.slice(0, 5)); + } else { + return resolve([ + "slight_smile", + "smile", + "wink", + "sunny", + "blush", + ]); + } + } + + // note this will only work for emojis starting with : + // eg: :-) + const emojiTranslation = + this.get("site.custom_emoji_translation") || {}; + const allTranslations = Object.assign( + {}, + translations, + emojiTranslation + ); + if (allTranslations[full]) { + return resolve([allTranslations[full]]); + } + + const match = term.match(/^:?(.*?):t([2-6])?$/); + if (match) { + const name = match[1]; + const scale = match[2]; + + if (isSkinTonableEmoji(name)) { + if (scale) { + return resolve([`${name}:t${scale}`]); + } else { + return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`)); + } + } + } + + const options = emojiSearch(term, { + maxResults: 5, + diversity: this.chatEmojiReactionStore.diversity, + }); + + return resolve(options); + }) + .then((list) => { + if (list === SKIP) { + return; + } + return list.map((code) => ({ code, src: emojiUrlFor(code) })); + }) + .then((list) => { + if (list?.length) { + list.push({ label: I18n.t("composer.more_emoji"), term }); + } + return list; + }); + }, + }); + }, + + @afterRender + _focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) { + if (this.chatChannel.isDraft) { + return; + } + + if (!this._textarea) { + return; + } + + if (opts.resizeTextarea) { + this.resizeTextarea(); + } + + if (opts.ensureAtEnd) { + this._textarea.setSelectionRange(this.value.length, this.value.length); + } + + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + schedule("afterRender", () => { + this._textarea?.focus(); + }); + }, + + @action + onEmojiSelected(code) { + this.emojiSelected(code); + this.set("emojiPickerIsActive", false); + }, + + @discourseComputed( + "chatChannel.{id,chatable.users.[]}", + "canInteractWithChat" + ) + disableComposer(channel, canInteractWithChat) { + return ( + (channel.isDraft && isEmpty(channel?.chatable?.users)) || + !canInteractWithChat || + !channel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}") + placeholder(userSilenced, chatChannel) { + if (!chatChannel.canModifyMessages(this.currentUser)) { + return I18n.t("chat.placeholder_new_message_disallowed", { + status: channelStatusName(chatChannel.status).toLowerCase(), + }); + } + + if (chatChannel.isDraft) { + return I18n.t("chat.placeholder_start_conversation", { + usernames: chatChannel?.chatable?.users?.length + ? chatChannel.chatable.users.mapBy("username").join(", ") + : "...", + }); + } + + if (userSilenced) { + return I18n.t("chat.placeholder_silenced"); + } else { + return this.messageRecipient(chatChannel); + } + }, + + messageRecipient(chatChannel) { + if (chatChannel.isDirectMessageChannel) { + const directMessageRecipients = chatChannel.chatable.users; + if ( + directMessageRecipients.length === 1 && + directMessageRecipients[0].id === this.currentUser.id + ) { + return I18n.t("chat.placeholder_self"); + } + + return I18n.t("chat.placeholder_others", { + messageRecipient: directMessageRecipients + .map((u) => u.name || `@${u.username}`) + .join(", "), + }); + } else { + return I18n.t("chat.placeholder_others", { + messageRecipient: `#${chatChannel.title}`, + }); + } + }, + + @discourseComputed( + "value", + "loading", + "disableComposer", + "inProgressUploads.[]" + ) + sendDisabled(value, loading, disableComposer, inProgressUploads) { + if (loading || disableComposer || inProgressUploads.length > 0) { + return true; + } + + return !this._messageIsValid(); + }, + + @action + sendClicked() { + if (this.site.mobileView) { + // prevents android to hide the keyboard after sending a message + // we do a focusTextarea later but it's too late for android + document.querySelector(this.composerFocusSelector).focus(); + } + + if (this.sendDisabled) { + return; + } + + this.editingMessage + ? this.internalEditMessage() + : this.internalSendMessage(); + }, + + @action + internalSendMessage() { + return this.sendMessage(this.value, this._uploads).then(this.reset); + }, + + @action + internalEditMessage() { + return this.editMessage( + this.editingMessage, + this.value, + this._uploads + ).then(this.reset); + }, + + _messageIsValid() { + const validLength = + (this.value || "").trim().length >= + (this.siteSettings.chat_minimum_message_length || 0); + + if (this.canAttachUploads) { + if (this._messageIsEmpty()) { + // If message is empty, an an upload must present for sending to be enabled + return this._uploads.length; + } else { + // Message is non-empty. Make sure it's long enough to be valid. + return validLength; + } + } + + // Attachments are disabled so for a message to be valid it must be long enough. + return validLength; + }, + + _messageIsEmpty() { + return (this.value || "").trim() === ""; + }, + + @action + reset() { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.setProperties({ + value: "", + inReplyMsg: null, + }); + this._syncUploads([]); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + cancelReplyTo() { + this.set("replyToMsg", null); + this.setInReplyToMsg(null); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + cancelEditing() { + this.onCancelEditing(); + this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); + }, + + _cursorIsOnEmptyLine() { + const selectionStart = this._textarea.selectionStart; + if (selectionStart === 0) { + return true; + } else if (this._textarea.value.charAt(selectionStart - 1) === "\n") { + return true; + } else { + return false; + } + }, + + @action + uploadsChanged(uploads) { + this.set("_uploads", cloneJSON(uploads)); + this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + }, + + @action + onTextareaFocusIn(target) { + if (!this.capabilities.isIOS) { + return; + } + + // hack to prevent the whole viewport + // to move on focus input + target = document.querySelector(".chat-composer-input"); + target.style.transform = "translateY(-99999px)"; + target.focus(); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + target.style.transform = ""; + }); + }); + }, + + @action + resizeTextarea() { + schedule("afterRender", () => { + if (!this._textarea) { + return; + } + + // this is a quirk which forces us to `auto` first or textarea + // won't resize + this._textarea.style.height = "auto"; + + // +1 is to workaround a rounding error visible on electron + // causing scrollbars to show when they shouldn’t + this._textarea.style.height = this._textarea.scrollHeight + 1 + "px"; + }); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js new file mode 100644 index 00000000000..e374564c35b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.js @@ -0,0 +1,53 @@ +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { inject as service } from "@ember/service"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { cloneJSON } from "discourse-common/lib/object"; +export default class ChatDraftChannelScreen extends Component { + @service chat; + @service router; + tagName = ""; + onSwitchChannel = null; + + @action + onCancelChatDraft() { + return this.router.transitionTo("chat.index"); + } + + @action + onChangeSelectedUsers(users) { + this._fetchPreviewedChannel(users); + } + + @action + onSwitchFromDraftChannel(channel) { + channel.set("isDraft", false); + this.onSwitchChannel?.(channel); + } + + _fetchPreviewedChannel(users) { + this.set("previewedChannel", null); + + return this.chat + .getDmChannelForUsernames(users.mapBy("username")) + .then((response) => { + this.set( + "previewedChannel", + ChatChannel.create( + Object.assign({}, response.chat_channel, { isDraft: true }) + ) + ); + }) + .catch((error) => { + if (error?.jqXHR?.status === 404) { + this.set( + "previewedChannel", + ChatChannel.create({ + chatable: { users: cloneJSON(users) }, + isDraft: true, + }) + ); + } + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js new file mode 100644 index 00000000000..4395e95d441 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -0,0 +1,368 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { emojiUrlFor } from "discourse/lib/text"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { bind } from "discourse-common/utils/decorators"; +import { later, schedule } from "@ember/runloop"; + +export const FITZPATRICK_MODIFIERS = [ + { + scale: 1, + modifier: null, + }, + { + scale: 2, + modifier: ":t2", + }, + { + scale: 3, + modifier: ":t3", + }, + { + scale: 4, + modifier: ":t4", + }, + { + scale: 5, + modifier: ":t5", + }, + { + scale: 6, + modifier: ":t6", + }, +]; + +export default class ChatEmojiPicker extends Component { + @service chatEmojiPickerManager; + @service emojiPickerScrollObserver; + @service chatEmojiReactionStore; + @tracked filteredEmojis = null; + @tracked isExpandedFitzpatrickScale = false; + tagName = ""; + + fitzpatrickModifiers = FITZPATRICK_MODIFIERS; + + get groups() { + const emojis = this.chatEmojiPickerManager.emojis; + const favorites = { + favorites: this.chatEmojiReactionStore.favorites.map((name) => { + return { + name, + group: "favorites", + url: emojiUrlFor(name), + }; + }), + }; + + return { + ...favorites, + ...emojis, + }; + } + + get flatEmojis() { + // eslint-disable-next-line no-unused-vars + let { favorites, ...rest } = this.chatEmojiPickerManager.emojis; + return Object.values(rest).flat(); + } + + get navIndicatorStyle() { + const section = this.chatEmojiPickerManager.lastVisibleSection; + const index = Object.keys(this.groups).indexOf(section); + + return htmlSafe( + `width: ${ + 100 / Object.keys(this.groups).length + }%; transform: translateX(${index * 100}%);` + ); + } + + get navBtnStyle() { + return htmlSafe(`width: ${100 / Object.keys(this.groups).length}%;`); + } + + @action + didPressEscape(event) { + if (event.key === "Escape") { + this.chatEmojiPickerManager.close(); + } + } + + @action + didNavigateFitzpatrickScale(event) { + if (event.type !== "keyup") { + return; + } + + const scaleNodes = + event.target + .closest(".chat-emoji-picker__fitzpatrick-scale") + ?.querySelectorAll(".chat-emoji-picker__fitzpatrick-modifier-btn") || + []; + + const scales = [...scaleNodes]; + + if (event.key === "ArrowRight") { + event.preventDefault(); + + if (event.target === scales[scales.length - 1]) { + scales[0].focus(); + } else { + event.target.nextElementSibling?.focus(); + } + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + + if (event.target === scales[0]) { + scales[scales.length - 1].focus(); + } else { + event.target.previousElementSibling?.focus(); + } + } + } + + @action + didToggleFitzpatrickScale(event) { + if (event.type === "keyup") { + if (event.key === "Escape") { + event.preventDefault(); + this.isExpandedFitzpatrickScale = false; + return; + } + + if (event.key !== "Enter") { + return; + } + } + + this.toggleProperty("isExpandedFitzpatrickScale"); + } + + @action + didRequestFitzpatrickScale(scale, event) { + if (event.type === "keyup") { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.isExpandedFitzpatrickScale = false; + this._focusCurrentFitzpatrickScale(); + return; + } + + if (event.key !== "Enter") { + return; + } + } + + event.preventDefault(); + event.stopPropagation(); + + this.isExpandedFitzpatrickScale = false; + this.chatEmojiReactionStore.diversity = scale; + this._focusCurrentFitzpatrickScale(); + } + + _focusCurrentFitzpatrickScale() { + schedule("afterRender", () => { + document + .querySelector(".chat-emoji-picker__fitzpatrick-modifier-btn.current") + ?.focus(); + }); + } + + @action + didInputFilter(event) { + if (!event.target.value.length) { + this.filteredEmojis = null; + return; + } + + discourseDebounce( + this, + this.debouncedDidInputFilter, + event.target.value, + INPUT_DELAY + ); + } + + @action + focusFilter(target) { + target.focus(); + } + + debouncedDidInputFilter(filter = "") { + filter = filter.toLowerCase(); + + this.filteredEmojis = this.flatEmojis.filter( + (emoji) => + emoji.name.toLowerCase().includes(filter) || + emoji.search_aliases?.any((alias) => + alias.toLowerCase().includes(filter) + ) + ); + + schedule("afterRender", () => { + const scrollableContent = document.querySelector( + ".chat-emoji-picker__scrollable-content" + ); + + if (scrollableContent) { + scrollableContent.scrollTop = 0; + } + }); + } + + @action + didNavigateSection(event) { + if (event.type !== "keyup") { + return; + } + + const sectionEmojis = [ + ...event.target + .closest(".chat-emoji-picker__section") + .querySelectorAll(".emoji"), + ]; + + if (event.key === "ArrowRight") { + event.preventDefault(); + + if (event.target === sectionEmojis[sectionEmojis.length - 1]) { + sectionEmojis[0].focus(); + } else { + event.target.nextElementSibling?.focus(); + } + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + + if (event.target === sectionEmojis[0]) { + sectionEmojis[sectionEmojis.length - 1].focus(); + } else { + event.target.previousElementSibling?.focus(); + } + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + + sectionEmojis + .filter((c) => c.offsetTop > event.target.offsetTop) + .find((c) => c.offsetLeft === event.target.offsetLeft) + ?.focus(); + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + + sectionEmojis + .reverse() + .filter((c) => c.offsetTop < event.target.offsetTop) + .find((c) => c.offsetLeft === event.target.offsetLeft) + ?.focus(); + } + } + + @action + didSelectEmoji(event) { + if (!event.target.classList.contains("emoji")) { + return; + } + + if ( + event.type === "click" || + (event.type === "keyup" && event.key === "Enter") + ) { + event.preventDefault(); + event.stopPropagation(); + const originalTarget = event.target; + let emoji = event.target.dataset.emoji; + const tonable = event.target.dataset.tonable; + const diversity = this.chatEmojiReactionStore.diversity; + if (tonable && diversity > 1) { + emoji = `${emoji}:t${diversity}`; + } + + this.chatEmojiPickerManager.didSelectEmoji(emoji); + + schedule("afterRender", () => { + originalTarget.focus(); + }); + } + } + + @action + didFocusFirstEmoji(event) { + event.preventDefault(); + const section = event.target.closest(".chat-emoji-picker__section").dataset + .section; + this.didRequestSection(section); + } + + @action + didRequestSection(section) { + const scrollableContent = document.querySelector( + ".chat-emoji-picker__scrollable-content" + ); + + this.filteredEmojis = null; + + // we disable scroll listener during requesting section + // to avoid it from detecting another section during scroll to requested section + this.emojiPickerScrollObserver.enabled = false; + this.chatEmojiPickerManager.addVisibleSections([section]); + this.chatEmojiPickerManager.lastVisibleSection = section; + + // iOS hack to avoid blank div when requesting section during momentum + if (scrollableContent && this.capabilities.isIOS) { + document.querySelector( + ".chat-emoji-picker__scrollable-content" + ).style.overflow = "hidden"; + } + + schedule("afterRender", () => { + document + .querySelector(`.chat-emoji-picker__section[data-section="${section}"]`) + .scrollIntoView({ + behavior: "auto", + block: "start", + inline: "nearest", + }); + + later(() => { + // iOS hack to avoid blank div when requesting section during momentum + if (scrollableContent && this.capabilities.isIOS) { + document.querySelector( + ".chat-emoji-picker__scrollable-content" + ).style.overflow = "scroll"; + } + + this.emojiPickerScrollObserver.enabled = true; + }, 200); + }); + } + + @action + addClickOutsideEventListener() { + document.addEventListener("click", this.didClickOutside); + } + + @action + removeClickOutsideEventListener() { + document.removeEventListener("click", this.didClickOutside); + } + + @bind + didClickOutside(event) { + if (!event.target.closest(".chat-emoji-picker")) { + this.chatEmojiPickerManager.close(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js new file mode 100644 index 00000000000..e7ed4f500aa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -0,0 +1,1508 @@ +import isElementInViewport from "discourse/lib/is-element-in-viewport"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import { cloneJSON } from "discourse-common/lib/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import Component from "@ember/component"; +import discourseComputed, { + afterRender, + bind, + observes, +} from "discourse-common/utils/decorators"; +import discourseDebounce from "discourse-common/lib/debounce"; +import EmberObject, { action } from "@ember/object"; +import I18n from "I18n"; +import { A } from "@ember/array"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { cancel, next, schedule, throttle } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; +import { inject as service } from "@ember/service"; +import { Promise } from "rsvp"; +import { resetIdle } from "discourse/lib/desktop-notifications"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { capitalize } from "@ember/string"; +import { + onPresenceChange, + removeOnPresenceChange, +} from "discourse/lib/user-presence"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; +import { isTesting } from "discourse-common/config/environment"; + +const MAX_RECENT_MSGS = 100; +const STICKY_SCROLL_LENIENCE = 50; +const PAGE_SIZE = 50; + +const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 100; +const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500; + +const PAST = "past"; +const FUTURE = "future"; + +export default Component.extend({ + classNameBindings: [":chat-live-pane", "sendingLoading", "loading"], + chatChannel: null, + fullPage: false, + registeredChatChannelId: null, // ?Number + loading: false, + loadingMorePast: false, + loadingMoreFuture: false, + hoveredMessageId: null, + onSwitchChannel: null, + + allPastMessagesLoaded: false, + sendingLoading: false, + selectingMessages: false, + stickyScroll: true, + stickyScrollTimer: null, + showChatQuoteSuccess: false, + showCloseFullScreenBtn: false, + includeHeader: true, + + editingMessage: null, // ?Message + replyToMsg: null, // ?Message + details: null, // Object { chat_channel_id, ... } + messages: null, // Array + messageLookup: null, // Object + _unloadedReplyIds: null, // Array + _nextStagedMessageId: 0, // Iterate on every new message + _lastSelectedMessage: null, + targetMessageId: null, + hasNewMessages: null, + + chat: service(), + router: service(), + chatEmojiPickerManager: service(), + chatComposerPresenceManager: service(), + fullPageChat: service(), + + getCachedChannelDetails: null, + clearCachedChannelDetails: null, + _scrollerEl: null, + + init() { + this._super(...arguments); + + this.set("messages", []); + }, + + didInsertElement() { + this._super(...arguments); + + this._unloadedReplyIds = []; + this.appEvents.on( + "chat-live-pane:highlight-message", + this, + "highlightOrFetchMessage" + ); + + this._scrollerEl = this.element.querySelector(".chat-messages-scroll"); + this._scrollerEl.addEventListener("scroll", this.onScrollHandler, { + passive: true, + }); + window.addEventListener("resize", this.onResizeHandler); + window.addEventListener("mousewheel", this.onScrollHandler, { + passive: true, + }); + + this.appEvents.on("chat:cancel-message-selection", this, "cancelSelecting"); + + this.set("showCloseFullScreenBtn", !this.site.mobileView); + + document.addEventListener("scroll", this._forceBodyScroll, { + passive: true, + }); + + onPresenceChange({ + callback: this.onPresenceChangeCallback, + }); + }, + + willDestroyElement() { + this._super(...arguments); + + this.element + .querySelector(".chat-messages-scroll") + ?.removeEventListener("scroll", this.onScrollHandler); + + window.removeEventListener("resize", this.onResizeHandler); + window.removeEventListener("mousewheel", this.onScrollHandler); + + this.appEvents.off( + "chat-live-pane:highlight-message", + this, + "highlightOrFetchMessage" + ); + + // don't need to removeEventListener from scroller as the DOM element goes away + cancel(this.stickyScrollTimer); + + cancel(this.resizeHandler); + + this._resetChannelState(); + this._unloadedReplyIds = null; + this.appEvents.off( + "chat:cancel-message-selection", + this, + "cancelSelecting" + ); + + document.removeEventListener("scroll", this._forceBodyScroll); + + removeOnPresenceChange(this.onPresenceChangeCallback); + }, + + didReceiveAttrs() { + this._super(...arguments); + + this.currentUserTimezone = this.currentUser?.resolvedTimezone( + this.currentUser + ); + + this.set("targetMessageId", this.chat.messageId); + + if ( + this.chatChannel?.id && + this.registeredChatChannelId !== this.chatChannel.id + ) { + this._resetChannelState(); + this.cancelEditing(); + + if (!this.chatChannel.isDraft) { + this.loadDraftForChannel(this.chatChannel.id); + } + } + + if (this.chatChannel?.id) { + this.fetchMessages(this.chatChannel); + } + }, + + @discourseComputed("chatChannel.isDirectMessageChannel") + displayMembers(isDirectMessageChannel) { + return !isDirectMessageChannel; + }, + + @discourseComputed("displayMembers") + infoTabRoute(displayMembers) { + if (displayMembers) { + return "chat.channel.info.members"; + } + + return "chat.channel.info.settings"; + }, + + @bind + onScrollHandler(event) { + throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, true); + }, + + @bind + onResizeHandler() { + cancel(this.resizeHandler); + this.resizeHandler = discourseDebounce( + this, + this.fillPaneAttempt, + this.details, + 250 + ); + }, + + @bind + onPresenceChangeCallback(present) { + if (present) { + this.chat.updateLastReadMessage(); + } + }, + + fetchMessages(channel, options = {}) { + this.set("loading", true); + + return this.chat.loadCookFunction(this.site.categories).then((cook) => { + if (this._selfDeleted) { + return; + } + + this.set("cook", cook); + + const findArgs = { + channelId: channel.id, + pageSize: PAGE_SIZE, + }; + const fetchingFromLastRead = !options.fetchFromLastMessage; + + if (fetchingFromLastRead) { + findArgs["targetMessageId"] = + this.targetMessageId || this._getLastReadId(); + } + + return this.store + .findAll("chat-message", findArgs) + .then((messages) => { + if (this._selfDeleted || this.chatChannel.id !== channel.id) { + return; + } + this.setMessageProps(messages, fetchingFromLastRead); + }) + .catch(this._handleErrors) + .finally(() => { + if (this._selfDeleted || this.chatChannel.id !== channel.id) { + return; + } + + this.chat.set("messageId", null); + this.set("loading", false); + + if (this.targetMessageId) { + this.highlightOrFetchMessage(this.targetMessageId); + } + + this.focusComposer(); + }); + }); + }, + + loadDraftForChannel(channelId) { + this.set("draft", this.chat.getDraftForChannel(channelId)); + }, + + @bind + _fetchMoreMessages(direction) { + const loadingPast = direction === PAST; + const canLoadMore = loadingPast + ? this.details?.can_load_more_past + : this.details?.can_load_more_future; + const loadingMoreKey = `loadingMore${capitalize(direction)}`; + const loadingMore = this.get(loadingMoreKey); + + if ( + (this.details && !canLoadMore) || + loadingMore || + this.loading || + !this.messages.length + ) { + return Promise.resolve(); + } + + this.set(loadingMoreKey, true); + this.ignoreStickyScrolling = true; + + const messageIndex = loadingPast ? 0 : this.messages.length - 1; + const messageId = this.messages[messageIndex].id; + const findArgs = { + channelId: this.chatChannel.id, + pageSize: PAGE_SIZE, + direction, + messageId, + }; + const channelId = this.chatChannel.id; + + return this.store + .findAll("chat-message", findArgs) + .then((messages) => { + if (this._selfDeleted || channelId !== this.chatChannel.id) { + return; + } + + const newMessages = this._prepareMessages(messages || []); + if (newMessages.length) { + this.set( + "messages", + loadingPast + ? newMessages.concat(this.messages) + : this.messages.concat(newMessages) + ); + } + this.setCanLoadMoreDetails(messages.resultSetMeta); + + if (!loadingPast && newMessages.length) { + // Adding newer messages also causes a scroll-down, + // firing another event, fetching messages again, and so on. + // Scroll to the first new one to prevent this. + this.scrollToMessage(newMessages.firstObject.messageLookupId); + } + + return messages; + }) + .catch(this._handleErrors) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set(loadingMoreKey, false); + this.ignoreStickyScrolling = false; + }); + }, + + fillPaneAttempt(meta) { + if (this._selfDeleted) { + return; + } + + // safeguard + if (this.messages.length > 200) { + return; + } + + if (!meta?.can_load_more_past) { + return; + } + + schedule("afterRender", () => { + const firstMessageId = this.messages.firstObject?.id; + if (!firstMessageId) { + return; + } + + const scroller = document.querySelector(".chat-messages-container"); + const messageContainer = document.querySelector( + `.chat-message-container[data-id="${firstMessageId}"]` + ); + if ( + !scroller || + !messageContainer || + !isElementInViewport(messageContainer) + ) { + return; + } + + this._fetchMoreMessagesThrottled(PAST); + }); + }, + + _fetchMoreMessagesThrottled(direction) { + throttle( + this, + "_fetchMoreMessages", + direction, + FETCH_MORE_MESSAGES_THROTTLE_MS + ); + }, + + setCanLoadMoreDetails(meta) { + const metaKeys = Object.keys(meta); + if (metaKeys.includes("can_load_more_past")) { + this.set("details.can_load_more_past", meta.can_load_more_past); + this.set( + "allPastMessagesLoaded", + this.details.can_load_more_past === false + ); + } + if (metaKeys.includes("can_load_more_future")) { + this.set("details.can_load_more_future", meta.can_load_more_future); + } + }, + + setMessageProps(messages, fetchingFromLastRead) { + this._unloadedReplyIds = []; + this.setProperties({ + messages: this._prepareMessages(messages), + details: { + chat_channel_id: this.chatChannel.id, + chatable_type: this.chatChannel.chatable_type, + can_delete_self: messages.resultSetMeta.can_delete_self, + can_delete_others: messages.resultSetMeta.can_delete_others, + can_flag: messages.resultSetMeta.can_flag, + user_silenced: messages.resultSetMeta.user_silenced, + can_moderate: messages.resultSetMeta.can_moderate, + }, + registeredChatChannelId: this.chatChannel.id, + }); + + schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + if (this.targetMessageId) { + this.scrollToMessage(this.targetMessageId, { + highlight: true, + position: "top", + autoExpand: true, + }); + this.set("targetMessageId", null); + } else if (fetchingFromLastRead) { + this._markLastReadMessage(); + } + + this.fillPaneAttempt(messages.resultSetMeta); + }); + + this.setCanLoadMoreDetails(messages.resultSetMeta); + this._subscribeToUpdates(this.chatChannel.id); + }, + + _prepareMessages(messages) { + const preparedMessages = A(); + let previousMessage; + messages.forEach((currentMessage) => { + let prepared = this._prepareSingleMessage( + currentMessage, + previousMessage + ); + preparedMessages.push(prepared); + previousMessage = prepared; + }); + return preparedMessages; + }, + + _areDatesOnSameDay(a, b) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); + }, + + _prepareSingleMessage(messageData, previousMessageData) { + if (previousMessageData) { + if ( + !this._areDatesOnSameDay( + new Date(previousMessageData.created_at), + new Date(messageData.created_at) + ) + ) { + messageData.firstMessageOfTheDayAt = moment( + messageData.created_at + ).calendar(moment(), { + sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, + lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, + lastWeek: "LL", + sameElse: "LL", + }); + } + } + if (messageData.in_reply_to?.id === previousMessageData?.id) { + // Reply-to message is directly above. Remove `in_reply_to` from message. + messageData.in_reply_to = null; + } + + if (messageData.in_reply_to) { + let inReplyToMessage = this.messageLookup[messageData.in_reply_to.id]; + if (inReplyToMessage) { + // Reply to message has already been added + messageData.in_reply_to = inReplyToMessage; + } else { + inReplyToMessage = EmberObject.create(messageData.in_reply_to); + this._unloadedReplyIds.push(inReplyToMessage.id); + this.messageLookup[inReplyToMessage.id] = inReplyToMessage; + } + } else { + // In reply-to is false. Check if previous message was created by same + // user and if so, no need to repeat avatar and username + + if ( + previousMessageData && + !previousMessageData.deleted_at && + Math.abs( + new Date(messageData.created_at) - + new Date(previousMessageData.created_at) + ) < 300000 && // If the time between messages is over 5 minutes, break. + messageData.user.id === previousMessageData.user.id + ) { + messageData.hideUserInfo = true; + } + } + this._handleMessageHidingAndExpansion(messageData); + messageData.messageLookupId = this._generateMessageLookupId(messageData); + const prepared = ChatMessage.create(messageData); + this.messageLookup[messageData.messageLookupId] = prepared; + return prepared; + }, + + _handleMessageHidingAndExpansion(messageData) { + if (this.currentUser.ignored_users) { + messageData.hidden = this.currentUser.ignored_users.includes( + messageData.user.username + ); + } + + // If a message has been hidden it is because the current user is ignoring + // the user who sent it, so we want to unconditionally hide it, even if + // we are going directly to the target + if (this.targetMessageId && this.targetMessageId === messageData.id) { + messageData.expanded = !messageData.hidden; + } else { + messageData.expanded = !(messageData.hidden || messageData.deleted_at); + } + }, + + _generateMessageLookupId(message) { + return message.id || `staged-${message.stagedId}`; + }, + + _getLastReadId() { + return this.currentUser?.chat_channel_tracking_state?.[this.chatChannel.id] + ?.chat_message_id; + }, + + _markLastReadMessage(opts = { reRender: false }) { + if (opts.reRender) { + this.messages.forEach((m) => { + if (m.newestMessage) { + m.set("newestMessage", false); + } + }); + } + const lastReadId = this._getLastReadId(); + if (!lastReadId) { + return; + } + + this.set("lastSendReadMessageId", lastReadId); + const indexOfLastReadMessage = + this.messages.findIndex((m) => m.id === lastReadId) || 0; + let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1]; + + if (newestUnreadMessage) { + newestUnreadMessage.set("newestMessage", true); + + next(() => this.scrollToMessage(newestUnreadMessage.id)); + + return; + } + this._stickScrollToBottom(); + }, + + highlightOrFetchMessage(messageId) { + if (this._selfDeleted) { + return; + } + + if (this.messageLookup[messageId]) { + // We have the message rendered. highlight and scrollTo + this.scrollToMessage(messageId, { + highlight: true, + position: "top", + autoExpand: true, + }); + } else { + this.set("targetMessageId", messageId); + this.fetchMessages(this.chatChannel); + } + }, + + scrollToMessage( + messageId, + opts = { highlight: false, position: "top", autoExpand: false } + ) { + if (this._selfDeleted) { + return; + } + const message = this.messageLookup[messageId]; + if (message?.deleted_at && opts.autoExpand) { + message.set("expanded", true); + } + + schedule("afterRender", () => { + const messageEl = this._scrollerEl.querySelector( + `.chat-message-container[data-id='${messageId}']` + ); + + if (!messageEl || this._selfDeleted) { + return; + } + + this._wrapIOSFix(() => { + messageEl.scrollIntoView({ + block: opts.position === "top" ? "start" : "end", + }); + }); + + if (opts.highlight) { + messageEl.classList.add("highlighted"); + + // Remove highlighted class, but keep `transition-slow` on for another 2 seconds + // to ensure the background color fades smoothly out + if (opts.highlight) { + discourseLater(() => { + messageEl.classList.add("transition-slow"); + }, 2000); + + discourseLater(() => { + messageEl.classList.remove("highlighted"); + + discourseLater(() => { + messageEl.classList.remove("transition-slow"); + }, 2000); + }, 3000); + } + } + }); + }, + + @afterRender + _stickScrollToBottom() { + if (this.ignoreStickyScrolling) { + return; + } + + this.set("stickyScroll", true); + + if (this._scrollerEl) { + // Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. + // Setting to just 0 doesn't work (it's at 0 by default, so there is no change) + // Very hacky, but no way to get around this Safari bug + this._scrollerEl.scrollTop = -1; + + this._wrapIOSFix(() => { + this._scrollerEl.scrollTop = 0; + this.set("showScrollToBottomBtn", false); + }); + } + }, + + onScroll(event) { + if (this._selfDeleted) { + return; + } + + resetIdle(); + + const atTop = + Math.abs( + this._scrollerEl.scrollHeight - + this._scrollerEl.clientHeight + + this._scrollerEl.scrollTop + ) <= STICKY_SCROLL_LENIENCE; + + if (atTop) { + this._fetchMoreMessagesThrottled(PAST); + } else if (Math.abs(this._scrollerEl.scrollTop) <= STICKY_SCROLL_LENIENCE) { + this._fetchMoreMessagesThrottled(FUTURE); + } + + this._calculateStickScroll(event.forceShowScrollToBottom); + }, + + _calculateStickScroll(forceShowScrollToBottom) { + const absoluteScrollTop = Math.abs(this._scrollerEl.scrollTop); + const shouldStick = absoluteScrollTop < STICKY_SCROLL_LENIENCE; + + if (forceShowScrollToBottom) { + this.set("showScrollToBottomBtn", forceShowScrollToBottom); + } else { + this.set( + "showScrollToBottomBtn", + shouldStick + ? false + : absoluteScrollTop / this._scrollerEl.offsetHeight > 0.67 + ); + } + + if (!this.showScrollToBottomBtn) { + this.set("hasNewMessages", false); + } + + if (shouldStick !== this.stickyScroll) { + if (shouldStick) { + this._stickScrollToBottom(); + } else { + this.set("stickyScroll", false); + } + } + }, + + @observes("floatHidden") + onFloatHiddenChange() { + if (!this.floatHidden) { + this.set("expanded", true); + this._markLastReadMessage({ reRender: true }); + this._stickScrollToBottom(); + } + }, + + removeMessage(msgData) { + delete this.messageLookup[msgData.id]; + }, + + handleMessage(data) { + switch (data.type) { + case "sent": + this.handleSentMessage(data); + break; + case "processed": + this.handleProcessedMessage(data); + break; + case "edit": + this.handleEditMessage(data); + break; + case "refresh": + this.handleRefreshMessage(data); + break; + case "delete": + this.handleDeleteMessage(data); + break; + case "bulk_delete": + this.handleBulkDeleteMessage(data); + break; + case "reaction": + this.handleReactionMessage(data); + break; + case "restore": + this.handleRestoreMessage(data); + break; + case "mention_warning": + this.handleMentionWarning(data); + break; + case "self_flagged": + this.handleSelfFlaggedMessage(data); + break; + case "flag": + this.handleFlaggedMessage(data); + break; + } + }, + + handleSentMessage(data) { + if (this.chatChannel.isFollowing) { + this.chatChannel.set("last_message_sent_at", new Date()); + } + + if (data.chat_message.user.id === this.currentUser.id) { + // User sent this message. Check staged messages to see if this client sent the message. + // If so, need to update the staged message with and id. + const stagedMessage = this.messageLookup[`staged-${data.stagedId}`]; + if (stagedMessage) { + stagedMessage.setProperties({ + error: null, + staged: false, + id: data.chat_message.id, + staged_id: null, + excerpt: data.chat_message.excerpt, + }); + + // some markdown is cooked differently on the server-side, e.g. + // quotes, avatar images etc. + if ( + data.chat_message.cooked && + data.chat_message.cooked !== stagedMessage.cooked + ) { + stagedMessage.set("cooked", data.chat_message.cooked); + } + this.appEvents.trigger( + `chat-message-staged-${data.stagedId}:id-populated` + ); + + this.messageLookup[data.chat_message.id] = stagedMessage; + delete this.messageLookup[`staged-${data.stagedId}`]; + return; + } + } + + const preparedMessage = this._prepareSingleMessage( + data.chat_message, + this.messages[this.messages.length - 1] + ); + + this.messages.pushObject(preparedMessage); + + if (this.messages.length >= MAX_RECENT_MSGS) { + this.removeMessage(this.messages.shiftObject()); + } + this.reStickScrollIfNeeded(); + }, + + handleProcessedMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + message.set("cooked", data.chat_message.cooked); + this.reStickScrollIfNeeded(); + } + }, + + handleRefreshMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + this.appEvents.trigger("chat:refresh-message", message); + } + }, + + handleEditMessage(data) { + const message = this.messageLookup[data.chat_message.id]; + if (message) { + message.setProperties({ + message: data.chat_message.message, + cooked: data.chat_message.cooked, + excerpt: data.chat_message.excerpt, + uploads: cloneJSON(data.chat_message.uploads || []), + edited: true, + }); + } + }, + + handleBulkDeleteMessage(data) { + data.deleted_ids.forEach((deletedId) => { + this.handleDeleteMessage({ + deleted_id: deletedId, + deleted_at: data.deleted_at, + }); + }); + }, + + handleDeleteMessage(data) { + const deletedId = data.deleted_id; + const targetMsg = this.messageLookup[deletedId]; + if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { + targetMsg.setProperties({ + deleted_at: data.deleted_at, + expanded: false, + }); + } else { + this.messages.removeObject(targetMsg); + this.messageLookup[deletedId] = null; + } + }, + + handleReactionMessage(data) { + this.appEvents.trigger( + `chat-message-${data.chat_message_id}:reaction`, + data + ); + }, + + handleRestoreMessage(data) { + let message = this.messageLookup[data.chat_message.id]; + if (message) { + message.set("deleted_at", null); + } else { + // The message isn't present in the list for this user. Find the index + // where we should push the message to. Binary search is O(log(n)) + let newMessageIndex = this.binarySearchForMessagePosition( + this.messages, + message + ); + const previousMessage = + newMessageIndex > 0 ? this.messages[newMessageIndex - 1] : null; + message = this._prepareSingleMessage(data.chat_message, previousMessage); + if (newMessageIndex === 0) { + return; + } // Restored post is too old to show + + this.messages.splice(newMessageIndex, 0, message); + this.notifyPropertyChange("messages"); + } + }, + + binarySearchForMessagePosition(messages, newMessage) { + const newMessageCreatedAt = Date.parse(newMessage.created_at); + if (newMessageCreatedAt < Date.parse(messages[0].created_at)) { + return 0; + } + if ( + newMessageCreatedAt > Date.parse(messages[messages.length - 1].created_at) + ) { + return messages.length; + } + let m = 0; + let n = messages.length - 1; + while (m <= n) { + let k = Math.floor((n + m) / 2); + let comparison = this.compareCreatedAt(newMessageCreatedAt, messages[k]); + if (comparison > 0) { + m = k + 1; + } else if (comparison < 0) { + n = k - 1; + } else { + return k; + } + } + return m; + }, + + compareCreatedAt(newMessageCreatedAt, comparatorMessage) { + const compareDate = Date.parse(comparatorMessage.created_at); + if (newMessageCreatedAt > compareDate) { + return 1; + } else if (newMessageCreatedAt < compareDate) { + return -1; + } + return 0; + }, + + handleMentionWarning(data) { + this.messageLookup[data.chat_message_id]?.set("mentionWarning", data); + }, + + handleSelfFlaggedMessage(data) { + this.messageLookup[data.chat_message_id]?.set( + "user_flag_status", + data.user_flag_status + ); + }, + + handleFlaggedMessage(data) { + this.messageLookup[data.chat_message_id]?.set( + "reviewable_id", + data.reviewable_id + ); + }, + + get _selfDeleted() { + return !this.element || this.isDestroying || this.isDestroyed; + }, + + @action + sendMessage(message, uploads = []) { + resetIdle(); + + if (this.sendingLoading) { + return; + } + + this.set("sendingLoading", true); + this._setDraftForChannel(null); + + // TODO: all send message logic is due for massive refactoring + // This is all the possible case Im currently aware of + // - messaging to a public channel where you are not a member yet (preview = true) + // - messaging to an existing direct channel you were not tracking yet through dm creator (channel draft) + // - messaging to a new direct channel through DM creator (channel draft) + // - message to a direct channel you were tracking (preview = false, not draft) + // - message to a public channel you were tracking (preview = false, not draft) + // - message to a channel when we haven't loaded all future messages yet. + if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { + this.set("loading", true); + + return this._upsertChannelWithMessage( + this.chatChannel, + message, + uploads + ).finally(() => { + if (this._selfDeleted) { + return; + } + this.set("loading", false); + this.set("sendingLoading", false); + this._resetAfterSend(); + this._stickScrollToBottom(); + }); + } + + this.set("_nextStagedMessageId", this._nextStagedMessageId + 1); + const cooked = this.cook(message); + const stagedId = this._nextStagedMessageId; + let data = { + message, + cooked, + staged_id: stagedId, + upload_ids: uploads.map((upload) => upload.id), + }; + if (this.replyToMsg) { + data.in_reply_to_id = this.replyToMsg.id; + } + + // Start ajax request but don't return here, we want to stage the message instantly when all messages are loaded. + // Otherwise, we'll fetch latest and scroll to the one we just created. + // Return a resolved promise below. + const msgCreationPromise = ChatApi.sendMessage(this.chatChannel.id, data) + .catch((error) => { + this._onSendError(data.staged_id, error); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + + if (this.details.can_load_more_future) { + msgCreationPromise.then(() => this._fetchAndScrollToLatest()); + } else { + const stagedMessage = this._prepareSingleMessage( + // We need to add the user and created at for presentation of staged message + { + message, + cooked, + stagedId, + uploads: cloneJSON(uploads), + staged: true, + user: this.currentUser, + in_reply_to: this.replyToMsg, + created_at: new Date(), + }, + this.messages[this.messages.length - 1] + ); + this.messages.pushObject(stagedMessage); + this._stickScrollToBottom(); + } + + this._resetAfterSend(); + this.appEvents.trigger("chat-composer:reply-to-set", null); + return Promise.resolve(); + }, + + async _upsertChannelWithMessage(channel, message, uploads) { + let promise; + + if (channel.isDirectMessageChannel || channel.isDraft) { + promise = this.chat.upsertDmChannelForUsernames( + channel.chatable.users.mapBy("username") + ); + } else { + promise = ChatApi.loading(channel.id).then(() => channel); + } + + return promise + .then((c) => { + c.current_user_membership.set("following", true); + return this.chat.startTrackingChannel(c); + }) + .then((c) => + ajax(`/chat/${c.id}.json`, { + type: "POST", + data: { + message, + upload_ids: (uploads || []).mapBy("id"), + }, + }).then(() => { + this.chat.forceRefreshChannels(); + this.onSwitchChannel(ChatChannel.create(c)); + }) + ); + }, + + _onSendError(stagedId, error) { + const stagedMessage = this.messageLookup[`staged-${stagedId}`]; + if (stagedMessage) { + if (error.jqXHR?.responseJSON?.errors?.length) { + stagedMessage.set("error", error.jqXHR.responseJSON.errors[0]); + } else { + this.chat.markNetworkAsUnreliable(); + stagedMessage.set("error", "network_error"); + } + } + + this._resetAfterSend(); + }, + + @action + resendStagedMessage(stagedMessage) { + this.set("sendingLoading", true); + + stagedMessage.set("error", null); + + const data = { + cooked: stagedMessage.cooked, + message: stagedMessage.message, + upload_ids: stagedMessage.upload_ids, + staged_id: stagedMessage.stagedId, + }; + + ChatApi.sendMessage(this.chatChannel.id, data) + .catch((error) => { + this._onSendError(data.staged_id, error); + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + }, + + @action + editMessage(chatMessage, newContent, uploads) { + this.set("sendingLoading", true); + let data = { + new_message: newContent, + upload_ids: (uploads || []).map((upload) => upload.id), + }; + return ajax(`/chat/${this.chatChannel.id}/edit/${chatMessage.id}`, { + type: "PUT", + data, + }) + .then(() => { + this._resetAfterSend(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.set("sendingLoading", false); + }); + }, + + _resetChannelState() { + this._unsubscribeToUpdates(this.registeredChatChannelId); + this.messages.clear(); + this.messageLookup = {}; + this.set("allPastMessagesLoaded", false); + this.set("registeredChatChannelId", null); + this.set("selectingMessages", false); + }, + + _resetAfterSend() { + if (this._selfDeleted) { + return; + } + this.setProperties({ + replyToMsg: null, + editingMessage: null, + }); + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, false); + }, + + @action + editLastMessageRequested() { + let lastUserMessage = null; + for ( + let messageIndex = this.messages.length - 1; + messageIndex >= 0; + messageIndex-- + ) { + let message = this.messages[messageIndex]; + if ( + !message.staged && + message.user.id === this.currentUser.id && + !message.error + ) { + lastUserMessage = message; + break; + } + } + if (lastUserMessage) { + this.set("editingMessage", lastUserMessage); + } + }, + + @action + setReplyTo(messageId) { + if (messageId) { + this.cancelEditing(); + this.set("replyToMsg", this.messageLookup[messageId]); + this.appEvents.trigger("chat-composer:reply-to-set", this.replyToMsg); + this._focusComposer(); + } else { + this.set("replyToMsg", null); + this.appEvents.trigger("chat-composer:reply-to-set", null); + } + }, + + @action + replyMessageClicked(message) { + const replyMessageFromLookup = this.messageLookup[message.id]; + if (this._unloadedReplyIds.includes(message.id)) { + // Message is not present in the loaded messages. Fetch it! + this.set("targetMessageId", message.id); + this.fetchMessages(this.chatChannel); + } else { + this.scrollToMessage(replyMessageFromLookup.id, { + highlight: true, + position: "top", + autoExpand: true, + }); + } + }, + + @action + editButtonClicked(messageId) { + const message = this.messageLookup[messageId]; + this.set("editingMessage", message); + next(this.reStickScrollIfNeeded.bind(this)); + this._focusComposer(); + }, + + @discourseComputed("details.user_silenced") + canInteractWithChat(userSilenced) { + return !userSilenced; + }, + + @discourseComputed + chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + }, + + @discourseComputed("messages.@each.selected") + selectedMessageIds(messages) { + return messages.filter((m) => m.selected).map((m) => m.id); + }, + + @action + onStartSelectingMessages(message) { + this._lastSelectedMessage = message; + this.set("selectingMessages", true); + }, + + @action + cancelSelecting() { + this.set("selectingMessages", false); + this.messages.setEach("selected", false); + }, + + @action + onSelectMessage(message) { + this._lastSelectedMessage = message; + }, + + @action + navigateToIndex() { + this.router.transitionTo("chat.index"); + }, + + @action + bulkSelectMessages(message, checked) { + const lastSelectedIndex = this._findIndexOfMessage( + this._lastSelectedMessage + ); + const newlySelectedIndex = this._findIndexOfMessage(message); + const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( + (a, b) => a - b + ); + + for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { + this.messages[i].set("selected", checked); + } + }, + + _findIndexOfMessage(message) { + return this.messages.findIndex((m) => m.id === message.id); + }, + + @action + onCloseFullScreen(channel) { + this.fullPageChat.isPreferred = false; + this.appEvents.trigger("chat:open-channel", channel); + + const previousRouteInfo = this.fullPageChat.exit(); + if (previousRouteInfo) { + this._transitionToRoute(previousRouteInfo); + } else { + this.router.transitionTo(`discovery.${defaultHomepage()}`); + } + }, + + @action + cancelEditing() { + this.set("editingMessage", null); + }, + + @action + _setDraftForChannel(draft) { + if (this.chatChannel.isDraft) { + return; + } + + if (draft?.replyToMsg) { + draft.replyToMsg = { + id: draft.replyToMsg.id, + excerpt: draft.replyToMsg.excerpt, + user: draft.replyToMsg.user, + }; + } + this.chat.setDraftForChannel(this.chatChannel, draft); + this.set("draft", draft); + }, + + @action + setInReplyToMsg(inReplyMsg) { + this.set("replyToMsg", inReplyMsg); + }, + + @action + composerValueChanged(value, uploads, replyToMsg) { + if (!this.editingMessage && !this.chatChannel.directMessageChannelDraft) { + this._setDraftForChannel({ value, uploads, replyToMsg }); + } + + if (!this.chatChannel.directMessageChannelDraft) { + this._reportReplyingPresence(value); + } + }, + + @action + reStickScrollIfNeeded() { + if (this.stickyScroll) { + this._stickScrollToBottom(); + } + }, + + @action + onHoverMessage(message, options = {}, event) { + cancel(this._onHoverMessageDebouncedHandler); + + if (this.site.mobileView && options.desktopOnly) { + return; + } + + if (message?.staged) { + return; + } + + if ( + this.hoveredMessageId && + message?.id && + this.hoveredMessageId === message?.id + ) { + return; + } + + if (event) { + if ( + event.type === "mouseleave" && + (event.toElement || event.relatedTarget)?.closest( + ".chat-message-actions-desktop-anchor" + ) + ) { + return; + } + + if ( + event.type === "mouseenter" && + (event.fromElement || event.relatedTarget)?.closest( + ".chat-message-actions-desktop-anchor" + ) + ) { + this.set("hoveredMessageId", message?.id); + return; + } + } + + this._onHoverMessageDebouncedHandler = discourseDebounce( + this, + this.debouncedOnHoverMessage, + message, + 250 + ); + }, + + @bind + debouncedOnHoverMessage(message) { + if (this._selfDeleted) { + return; + } + + this.set( + "hoveredMessageId", + message?.id && message.id !== this.hoveredMessageId ? message.id : null + ); + }, + + _reportReplyingPresence(composerValue) { + if (this.chatChannel.isDraft) { + return; + } + + const replying = !this.editingMessage && !!composerValue; + this.chatComposerPresenceManager.notifyState(this.chatChannel.id, replying); + }, + + @action + restickScrolling(event) { + event.preventDefault(); + + return this._fetchAndScrollToLatest(); + }, + + focusComposer() { + if ( + this._selfDeleted || + this.site.mobileView || + this.chatChannel?.isDraft + ) { + return; + } + + schedule("afterRender", () => { + document.querySelector(".chat-composer-input")?.focus(); + }); + }, + + @afterRender + _focusComposer() { + this.appEvents.trigger("chat:focus-composer"); + }, + + _unsubscribeToUpdates(channelId) { + this.messageBus.unsubscribe(`/chat/${channelId}`); + }, + + _subscribeToUpdates(channelId) { + this._unsubscribeToUpdates(channelId); + this.messageBus.subscribe(`/chat/${channelId}`, (busData) => { + if (!this.details.can_load_more_future || busData.type !== "sent") { + this.handleMessage(busData); + } else { + this.set("hasNewMessages", true); + } + }); + }, + + _transitionToRoute(routeInfo) { + const routeName = routeInfo.name; + let params = []; + + do { + params = Object.values(routeInfo.params).concat(params); + routeInfo = routeInfo.parent; + } while (routeInfo); + + this.router.transitionTo(routeName, ...params); + }, + + @bind + _forceBodyScroll() { + // when keyboard is visible this will ensure body + // doesn’t scroll out of viewport + if ( + this.capabilities.isIOS && + document.documentElement.classList.contains("keyboard-visible") && + !isZoomed() + ) { + document.documentElement.scrollTo(0, 0); + } + }, + + _fetchAndScrollToLatest() { + return this.fetchMessages(this.chatChannel, { + fetchFromLastMessage: true, + }).then(() => { + if (this._selfDeleted) { + return; + } + + this.set("stickyScroll", true); + this._stickScrollToBottom(); + }); + }, + + _handleErrors(error) { + switch (error?.jqXHR?.status) { + case 429: + case 404: + popupAjaxError(error); + break; + default: + throw error; + } + }, + + // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling + // we now use this hack to disable it + @bind + _wrapIOSFix(callback) { + if (!this._scrollerEl) { + return; + } + + if (this.capabilities.isIOS) { + this._scrollerEl.style.overflow = "hidden"; + } + + callback(); + + if (this.capabilities.isIOS) { + discourseLater(() => { + if (!this._scrollerEl) { + return; + } + + this._scrollerEl.style.overflow = "auto"; + }, 25); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js new file mode 100644 index 00000000000..d5f8765984d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js @@ -0,0 +1,53 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { createPopper } from "@popperjs/core"; +import { schedule } from "@ember/runloop"; + +const MSG_ACTIONS_PADDING = 2; + +export default Component.extend({ + tagName: "", + + messageActions: null, + + didReceiveAttrs() { + this._super(...arguments); + + this.popper?.destroy(); + + schedule("afterRender", () => { + this.popper = createPopper( + document.querySelector( + `.chat-message-container[data-id="${this.message.id}"]` + ), + document.querySelector( + `.chat-msgactions-hover[data-id="${this.message.id}"] .chat-msgactions` + ), + { + placement: "right-start", + modifiers: [ + { name: "hide", enabled: true }, + { + name: "offset", + options: { + offset: ({ popper, placement }) => { + return [ + MSG_ACTIONS_PADDING, + -(placement.includes("left") || placement.includes("right") + ? popper.width + MSG_ACTIONS_PADDING + : popper.height), + ]; + }, + }, + }, + ], + } + ); + }); + }, + + @action + handleSecondaryButtons(id) { + this.messageActions?.[id]?.(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js new file mode 100644 index 00000000000..5ffb58def35 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js @@ -0,0 +1,66 @@ +import Component from "@ember/component"; +import discourseLater from "discourse-common/lib/later"; +import { action } from "@ember/object"; +import { isTesting } from "discourse-common/config/environment"; + +export default Component.extend({ + tagName: "", + hasExpandedReply: false, + messageActions: null, + + didInsertElement() { + this._super(...arguments); + + discourseLater(this._addFadeIn); + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + }, + + @action + expandReply(event) { + event.stopPropagation(); + this.set("hasExpandedReply", true); + }, + + @action + collapseMenu(event) { + event.stopPropagation(); + this.onCloseMenu(); + }, + + @action + actAndCloseMenu(fn) { + fn?.(); + this.onCloseMenu(); + }, + + onCloseMenu() { + this._removeFadeIn(); + + // we don't want to remove the component right away as it's animating + // 200 is equal to the duration of the css animation + discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + // by ensuring we are not hovering any message anymore + // we also ensure the menu is fully removed + this.onHoverMessage?.(null); + }, 200); + }, + + _addFadeIn() { + document + .querySelector(".chat-msgactions-backdrop") + ?.classList.add("fade-in"); + }, + + _removeFadeIn() { + document + .querySelector(".chat-msgactions-backdrop") + ?.classList?.remove("fade-in"); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js new file mode 100644 index 00000000000..5b7b32a549a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-avatar.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default class ChatMessageAvatar extends Component { + tagName = ""; +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js new file mode 100644 index 00000000000..ab91763bd8d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -0,0 +1,214 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import { escapeExpression } from "discourse/lib/utilities"; +import domFromString from "discourse-common/lib/dom-from-string"; +import I18n from "I18n"; + +export default class ChatMessageCollapser extends Component { + tagName = ""; + collapsed = false; + uploads = null; + cooked = null; + + @computed("uploads") + get hasUploads() { + return hasUploads(this.uploads); + } + + @computed("uploads") + get uploadsHeader() { + let name = ""; + if (this.uploads.length === 1) { + name = this.uploads[0].original_filename; + } else { + name = I18n.t("chat.uploaded_files", { count: this.uploads.length }); + } + return htmlSafe( + `${escapeExpression( + name + )}` + ); + } + + @computed("cooked") + get cookedBodies() { + const elements = Array.prototype.slice.call(domFromString(this.cooked)); + + if (hasYoutube(elements)) { + return this.youtubeCooked(elements); + } + + if (hasImageOnebox(elements)) { + return this.imageOneboxCooked(elements); + } + + if (hasImage(elements)) { + return this.imageCooked(elements); + } + + if (hasGallery(elements)) { + return this.galleryCooked(elements); + } + + return []; + } + + youtubeCooked(elements) { + return elements.reduce((acc, e) => { + if (youtubePredicate(e)) { + const id = e.dataset.youtubeId; + const link = `https://www.youtube.com/watch?v=${escapeExpression(id)}`; + const title = escapeExpression(e.dataset.youtubeTitle); + const header = htmlSafe( + `${title}` + ); + const body = document.createElement("div"); + body.className = "chat-message-collapser-youtube"; + body.appendChild(e); + + acc.push({ header, body, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + imageOneboxCooked(elements) { + return elements.reduce((acc, e) => { + if (imageOneboxPredicate(e)) { + let link = animatedImagePredicate(e) + ? e.firstChild.src + : e.firstElementChild.href; + + link = escapeExpression(link); + const header = htmlSafe( + `${link}` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + imageCooked(elements) { + return elements.reduce((acc, e) => { + if (imagePredicate(e)) { + const link = escapeExpression(e.firstElementChild.src); + const alt = escapeExpression(e.firstElementChild.alt); + const header = htmlSafe( + `${ + alt || link + }` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } + + galleryCooked(elements) { + return elements.reduce((acc, e) => { + if (galleryPredicate(e)) { + const link = escapeExpression(e.firstElementChild.href); + const title = escapeExpression( + e.firstElementChild.firstElementChild.textContent + ); + e.firstElementChild.removeChild(e.firstElementChild.firstElementChild); + const header = htmlSafe( + `${title}` + ); + acc.push({ header, body: e, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } + return acc; + }, []); + } +} + +function youtubePredicate(e) { + return ( + e.classList.length && + e.classList.contains("onebox") && + e.classList.contains("lazyYT-container") + ); +} + +function hasYoutube(elements) { + return elements.some((e) => youtubePredicate(e)); +} + +function animatedImagePredicate(e) { + return ( + e.firstChild && + e.firstChild.nodeName === "IMG" && + e.firstChild.classList.contains("animated") && + e.firstChild.classList.contains("onebox") + ); +} + +function externalImageOnebox(e) { + return ( + e.firstElementChild && + e.firstElementChild.nodeName === "A" && + e.firstElementChild.classList.contains("onebox") && + e.firstElementChild.firstElementChild && + e.firstElementChild.firstElementChild.nodeName === "IMG" + ); +} + +function imageOneboxPredicate(e) { + return animatedImagePredicate(e) || externalImageOnebox(e); +} + +function hasImageOnebox(elements) { + return elements.some((e) => imageOneboxPredicate(e)); +} + +function hasUploads(uploads) { + return uploads?.length > 0; +} + +function imagePredicate(e) { + return ( + e.nodeName === "P" && + e.firstElementChild && + e.firstElementChild.nodeName === "IMG" && + !e.firstElementChild.classList.contains("emoji") + ); +} + +function hasImage(elements) { + return elements.some((e) => imagePredicate(e)); +} + +function galleryPredicate(e) { + return ( + e.firstElementChild && + e.firstElementChild.nodeName === "A" && + e.firstElementChild.firstElementChild && + e.firstElementChild.firstElementChild.classList.contains("outer-box") + ); +} + +function hasGallery(elements) { + return elements.some((e) => galleryPredicate(e)); +} + +export function isCollapsible(cooked, uploads) { + const elements = Array.prototype.slice.call(domFromString(cooked)); + + return ( + hasYoutube(elements) || + hasImageOnebox(elements) || + hasUploads(uploads) || + hasImage(elements) || + hasGallery(elements) + ); +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js new file mode 100644 index 00000000000..9bb31d6f4ce --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-info.js @@ -0,0 +1,70 @@ +import { computed } from "@ember/object"; +import Component from "@ember/component"; +import { prioritizeNameInUx } from "discourse/lib/settings"; + +export default class ChatMessageInfo extends Component { + tagName = ""; + message = null; + details = null; + + didInsertElement() { + this._super(...arguments); + this.message.user?.trackStatus?.(); + } + + willDestroyElement() { + this._super(...arguments); + this.message.user?.stopTrackingStatus?.(); + } + + @computed("message.user") + get name() { + return this.prioritizeName + ? this.message.user.name + : this.message.user.username; + } + + @computed("message.reviewable_id", "message.user_flag_status") + get isFlagged() { + return this.message?.reviewable_id || this.message?.user_flag_status === 0; + } + + @computed("message.user.name") + get prioritizeName() { + return ( + this.siteSettings.display_name_on_posts && + prioritizeNameInUx(this.message?.user?.name) + ); + } + + @computed("message.user.status") + get showStatus() { + return !!this.message.user?.status; + } + + @computed("message.user") + get usernameClasses() { + const user = this.message?.user; + + const classes = this.prioritizeName ? ["is-full-name"] : ["is-username"]; + + if (!user) { + return classes; + } + + if (user.staff) { + classes.push("is-staff"); + } + if (user.admin) { + classes.push("is-admin"); + } + if (user.moderator) { + classes.push("is-moderator"); + } + if (user.groupModerator) { + classes.push("is-category-moderator"); + } + + return classes.join(" "); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js new file mode 100644 index 00000000000..3045b7f4ed7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js @@ -0,0 +1,65 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import { reads } from "@ember/object/computed"; +import { isBlank } from "@ember/utils"; +import { action, computed } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { htmlSafe } from "@ember/template"; + +export default class MoveToChannelModalInner extends Component { + @service chat; + @service router; + tagName = ""; + sourceChannel = null; + destinationChannelId = null; + selectedMessageIds = null; + + @reads("selectedMessageIds.length") selectedMessageCount; + + @computed("destinationChannelId") + get disableMoveButton() { + return isBlank(this.destinationChannelId); + } + + @computed("chat.publicChannels.[]") + get availableChannels() { + return this.chat.publicChannels.rejectBy("id", this.sourceChannel.id); + } + + @action + moveMessages() { + return ajax( + `/chat/${this.sourceChannel.id}/move_messages_to_channel.json`, + { + method: "PUT", + data: { + message_ids: this.selectedMessageIds, + destination_channel_id: this.destinationChannelId, + }, + } + ) + .then((response) => { + this.router.transitionTo( + "chat.channel", + response.destination_channel_id, + response.destination_channel_title, + { + queryParams: { messageId: response.first_moved_message_id }, + } + ); + }) + .catch(popupAjaxError); + } + + @computed() + get instructionsText() { + return htmlSafe( + I18n.t("chat.move_to_channel.instructions", { + channelTitle: this.sourceChannel.escapedTitle, + count: this.selectedMessageCount, + }) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js new file mode 100644 index 00000000000..a15cdc06b73 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -0,0 +1,119 @@ +import { guidFor } from "@ember/object/internals"; +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { emojiUnescape, emojiUrlFor } from "discourse/lib/text"; +import setupPopover from "discourse/lib/d-popover"; +import I18n from "I18n"; +import { schedule } from "@ember/runloop"; + +export default class ChatMessageReaction extends Component { + reaction = null; + showUsersList = false; + tagName = ""; + react = null; + class = null; + + didReceiveAttrs() { + this._super(...arguments); + + if (this.showUsersList) { + schedule("afterRender", () => { + this._popover?.destroy(); + this._popover = this._setupPopover(); + }); + } + } + + willDestroyElement() { + this._super(...arguments); + + this._popover?.destroy(); + } + + @computed + get componentId() { + return guidFor(this); + } + + @computed("reaction.emoji") + get emojiString() { + return `:${this.reaction.emoji}:`; + } + + @computed("reaction.emoji") + get emojiUrl() { + return emojiUrlFor(this.reaction.emoji); + } + + @action + handleClick() { + this?.react(this.reaction.emoji, this.reaction.reacted ? "remove" : "add"); + return false; + } + + _setupPopover() { + const target = document.getElementById(this.componentId); + + if (!target) { + return; + } + + const popover = setupPopover(target, { + interactive: false, + allowHTML: true, + delay: 250, + content: emojiUnescape(this.popoverContent), + onClickOutside(instance) { + instance.hide(); + }, + onTrigger(instance, event) { + // ensures we close other reactions popovers when triggering one + document + .querySelectorAll(".chat-message-reaction") + .forEach((chatMessageReaction) => { + chatMessageReaction?._tippy?.hide(); + }); + + event.stopPropagation(); + }, + }); + + return popover?.id ? popover : null; + } + + @computed("reaction") + get popoverContent() { + let usernames = this.reaction.users.mapBy("username").join(", "); + if (this.reaction.reacted) { + if (this.reaction.count === 1) { + return I18n.t("chat.reactions.only_you", { + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count > 1 && this.reaction.count < 6) { + return I18n.t("chat.reactions.and_others", { + usernames, + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count >= 6) { + return I18n.t("chat.reactions.you_others_and_more", { + usernames, + emoji: this.reaction.emoji, + more: this.reaction.count - 5, + }); + } + } else { + if (this.reaction.count > 0 && this.reaction.count < 6) { + return I18n.t("chat.reactions.only_others", { + usernames, + emoji: this.reaction.emoji, + }); + } else if (this.reaction.count >= 6) { + return I18n.t("chat.reactions.others_and_more", { + usernames, + emoji: this.reaction.emoji, + more: this.reaction.count - 5, + }); + } + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js new file mode 100644 index 00000000000..44494409ab5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-separator.js @@ -0,0 +1,5 @@ +import Component from "@ember/component"; + +export default Component.extend({ + tagName: "", +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js new file mode 100644 index 00000000000..d02c47ba1cc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-text.js @@ -0,0 +1,15 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { isCollapsible } from "discourse/plugins/chat/discourse/components/chat-message-collapser"; + +export default class ChatMessageText extends Component { + tagName = ""; + cooked = null; + uploads = null; + edited = false; + + @computed("cooked", "uploads.[]") + get isCollapsible() { + return isCollapsible(this.cooked, this.uploads); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js new file mode 100644 index 00000000000..139b8291ee6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -0,0 +1,820 @@ +import Bookmark from "discourse/models/bookmark"; +import { openBookmarkModal } from "discourse/controllers/bookmark"; +import { isTesting } from "discourse-common/config/environment"; +import Component from "@ember/component"; +import I18n from "I18n"; +import getURL from "discourse-common/lib/get-url"; +import optionalService from "discourse/lib/optional-service"; +import discourseComputed, { + afterRender, + bind, +} from "discourse-common/utils/decorators"; +import EmberObject, { action, computed } from "@ember/object"; +import { and, not } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { cancel, once } from "@ember/runloop"; +import { clipboardCopy } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseLater from "discourse-common/lib/later"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; +import showModal from "discourse/lib/show-modal"; +import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; + +let _chatMessageDecorators = []; + +export function addChatMessageDecorator(decorator) { + _chatMessageDecorators.push(decorator); +} + +export function resetChatMessageDecorators() { + _chatMessageDecorators = []; +} + +export const MENTION_KEYWORDS = ["here", "all"]; + +export default Component.extend({ + ADD_REACTION: "add", + REMOVE_REACTION: "remove", + SHOW_LEFT: "showLeft", + SHOW_RIGHT: "showRight", + canInteractWithChat: false, + isHovered: false, + onHoverMessage: null, + mentionWarning: null, + chatEmojiReactionStore: service("chat-emoji-reaction-store"), + chatEmojiPickerManager: service("chat-emoji-picker-manager"), + adminTools: optionalService(), + _hasSubscribedToAppEvents: false, + tagName: "", + chat: service(), + dialog: service(), + chatMessageActionsMobileAnchor: null, + chatMessageActionsDesktopAnchor: null, + chatMessageEmojiPickerAnchor: null, + cachedFavoritesReactions: null, + + init() { + this._super(...arguments); + + this.set("_loadingReactions", []); + this.message.set("reactions", EmberObject.create(this.message.reactions)); + this.message.id + ? this._subscribeToAppEvents() + : this._waitForIdToBePopulated(); + if (this.message.bookmark) { + this.set("message.bookmark", Bookmark.create(this.message.bookmark)); + } + }, + + didInsertElement() { + this._super(...arguments); + + this.set( + "chatMessageActionsMobileAnchor", + document.querySelector(".chat-message-actions-mobile-anchor") + ); + this.set( + "chatMessageActionsDesktopAnchor", + document.querySelector(".chat-message-actions-desktop-anchor") + ); + + this.set("cachedFavoritesReactions", this.chatEmojiReactionStore.favorites); + }, + + willDestroyElement() { + this._super(...arguments); + if (this.message.stagedId) { + this.appEvents.off( + `chat-message-staged-${this.message.stagedId}:id-populated`, + this, + "_subscribeToAppEvents" + ); + } + + this.appEvents.off("chat:refresh-message", this, "_refreshedMessage"); + + this.appEvents.off( + `chat-message-${this.message.id}:reaction`, + this, + "_handleReactionMessage" + ); + + cancel(this._invitationSentTimer); + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.show || this.deletedAndCollapsed) { + this._decoratedMessageCooked = null; + } else if (this.message.cooked !== this._decoratedMessageCooked) { + once("afterRender", this.decorateMessageCooked); + this._decoratedMessageCooked = this.message.cooked; + } + }, + + @bind + _refreshedMessage(message) { + if (message.id === this.message.id) { + this.decorateMessageCooked(); + } + }, + + @bind + decorateMessageCooked() { + if (!this.messageContainer) { + return; + } + + _chatMessageDecorators.forEach((decorator) => { + decorator.call(this, this.messageContainer, this.chatChannel); + }); + }, + + @computed("message.{id,stagedId}") + get messageContainer() { + const id = this.message?.id || this.message?.stagedId; + return ( + id && document.querySelector(`.chat-message-container[data-id='${id}']`) + ); + }, + + _subscribeToAppEvents() { + if (!this.message.id || this._hasSubscribedToAppEvents) { + return; + } + + this.appEvents.on("chat:refresh-message", this, "_refreshedMessage"); + + this.appEvents.on( + `chat-message-${this.message.id}:reaction`, + this, + "_handleReactionMessage" + ); + this._hasSubscribedToAppEvents = true; + }, + + _waitForIdToBePopulated() { + this.appEvents.on( + `chat-message-staged-${this.message.stagedId}:id-populated`, + this, + "_subscribeToAppEvents" + ); + }, + + @discourseComputed("canInteractWithChat", "message.staged", "isHovered") + showActions(canInteractWithChat, messageStaged, isHovered) { + return canInteractWithChat && !messageStaged && isHovered; + }, + + deletedAndCollapsed: and("message.deleted_at", "collapsed"), + hiddenAndCollapsed: and("message.hidden", "collapsed"), + collapsed: not("message.expanded"), + + @discourseComputed( + "selectingMessages", + "canFlagMessage", + "showDeleteButton", + "showRestoreButton", + "showEditButton", + "showRebakeButton" + ) + secondaryButtons() { + const buttons = []; + + buttons.push({ + id: "copyLinkToMessage", + name: I18n.t("chat.copy_link"), + icon: "link", + }); + + if (this.showEditButton) { + buttons.push({ + id: "edit", + name: I18n.t("chat.edit"), + icon: "pencil-alt", + }); + } + + if (!this.selectingMessages) { + buttons.push({ + id: "selectMessage", + name: I18n.t("chat.select"), + icon: "tasks", + }); + } + + if (this.canFlagMessage) { + buttons.push({ + id: "flag", + name: I18n.t("chat.flag"), + icon: "flag", + }); + } + + if (this.showSilenceButton) { + buttons.push({ + id: "silence", + name: I18n.t("chat.silence"), + icon: "microphone-slash", + }); + } + + if (this.showDeleteButton) { + buttons.push({ + id: "deleteMessage", + name: I18n.t("chat.delete"), + icon: "trash-alt", + }); + } + + if (this.showRestoreButton) { + buttons.push({ + id: "restore", + name: I18n.t("chat.restore"), + icon: "undo", + }); + } + + if (this.showRebakeButton) { + buttons.push({ + id: "rebakeMessage", + name: I18n.t("chat.rebake_message"), + icon: "sync-alt", + }); + } + + return buttons; + }, + + get messageActions() { + return { + reply: this.reply, + react: this.react, + copyLinkToMessage: this.copyLinkToMessage, + edit: this.edit, + selectMessage: this.selectMessage, + flag: this.flag, + silence: this.silence, + deleteMessage: this.deleteMessage, + restore: this.restore, + rebakeMessage: this.rebakeMessage, + toggleBookmark: this.toggleBookmark, + startReactionForMsgActions: this.startReactionForMsgActions, + }; + }, + + get messageCapabilities() { + return { + canReact: this.canReact, + canReply: this.canReply, + canBookmark: this.showBookmarkButton, + }; + }, + + @discourseComputed("message", "details.can_moderate") + show(message, canModerate) { + return ( + !message.deleted_at || + this.currentUser.id === this.message.user.id || + this.currentUser.staff || + canModerate + ); + }, + + @action + handleTouchStart() { + // if zoomed don't track long press + if (isZoomed()) { + return; + } + + if (!this.isHovered) { + // when testing this must be triggered immediately because there + // is no concept of "long press" there, the Ember `tap` test helper + // does send the touchstart/touchend events but immediately, see + // https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap + if (isTesting()) { + this._handleLongPress(); + } + + this._isPressingHandler = discourseLater(this._handleLongPress, 500); + } + }, + + @action + handleTouchMove() { + if (!this.isHovered) { + cancel(this._isPressingHandler); + } + }, + + @action + handleTouchEnd() { + cancel(this._isPressingHandler); + }, + + @action + _handleLongPress() { + if (isZoomed()) { + // if zoomed don't handle long press + return; + } + + document.activeElement.blur(); + document.querySelector(".chat-composer-input")?.blur(); + + this.onHoverMessage(this.message); + }, + + @discourseComputed("message.hideUserInfo", "message.chat_webhook_event") + hideUserInfo(hide, webhookEvent) { + return hide && !webhookEvent; + }, + + @discourseComputed( + "message.staged", + "message.deleted_at", + "message.in_reply_to", + "message.error", + "message.bookmark", + "isHovered" + ) + chatMessageClasses(staged, deletedAt, inReplyTo, error, bookmark, isHovered) { + let classNames = ["chat-message"]; + + if (staged) { + classNames.push("chat-message-staged"); + } + if (deletedAt) { + classNames.push("deleted"); + } + if (inReplyTo) { + classNames.push("is-reply"); + } + if (this.hideUserInfo) { + classNames.push("user-info-hidden"); + } + if (error) { + classNames.push("errored"); + } + if (isHovered) { + classNames.push("chat-message-selected"); + } + if (bookmark) { + classNames.push("chat-message-bookmarked"); + } + return classNames.join(" "); + }, + + @discourseComputed("message", "message.deleted_at", "chatChannel.status") + showEditButton(message, deletedAt) { + return ( + !deletedAt && + this.currentUser.id === message.user?.id && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed( + "message", + "message.user_flag_status", + "details.can_flag", + "message.deleted_at" + ) + canFlagMessage(message, userFlagStatus, canFlag, deletedAt) { + return ( + this.currentUser?.id !== message.user?.id && + userFlagStatus === undefined && + canFlag && + !message.chat_webhook_event && + !deletedAt + ); + }, + + @discourseComputed("message") + showSilenceButton(message) { + return ( + this.currentUser?.staff && + this.currentUser?.id !== message.user?.id && + !message.chat_webhook_event + ); + }, + + @discourseComputed("message") + canManageDeletion(message) { + return this.currentUser?.id === message.user?.id + ? this.details.can_delete_self + : this.details.can_delete_others; + }, + + @discourseComputed("message.deleted_at", "chatChannel.status") + canReply(deletedAt) { + return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed("message.deleted_at", "chatChannel.status") + canReact(deletedAt) { + return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed( + "canManageDeletion", + "message.deleted_at", + "chatChannel.status" + ) + showDeleteButton(canManageDeletion, deletedAt) { + return ( + canManageDeletion && + !deletedAt && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed( + "canManageDeletion", + "message.deleted_at", + "chatChannel.status" + ) + showRestoreButton(canManageDeletion, deletedAt) { + return ( + canManageDeletion && + deletedAt && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("chatChannel.status") + showBookmarkButton() { + return this.chatChannel.canModifyMessages(this.currentUser); + }, + + @discourseComputed("chatChannel.status") + showRebakeButton() { + return ( + this.currentUser?.staff && + this.chatChannel.canModifyMessages(this.currentUser) + ); + }, + + @discourseComputed("message.reactions.@each") + hasReactions(reactions) { + return Object.values(reactions).some((r) => r.count > 0); + }, + + @discourseComputed("message.mentionWarning.cannot_see") + mentionedCannotSeeText(users) { + return I18n.t("chat.mention_warning.cannot_see", { + usernames: users.mapBy("username").join(", "), + count: users.length, + }); + }, + + @discourseComputed("message.mentionWarning.without_membership") + mentionedWithoutMembershipText(users) { + return I18n.t("chat.mention_warning.without_membership", { + usernames: users.mapBy("username").join(", "), + count: users.length, + }); + }, + + @action + inviteMentioned() { + const user_ids = this.message.mentionWarning.without_membership.mapBy("id"); + + ajax(`/chat/${this.details.chat_channel_id}/invite`, { + method: "PUT", + data: { user_ids, chat_message_id: this.message.id }, + }).then(() => { + this.message.set("mentionWarning.invitationSent", true); + this._invitationSentTimer = discourseLater(() => { + this.message.set("mentionWarning", null); + }, 3000); + }); + + return false; + }, + + @action + dismissMentionWarning() { + this.message.set("mentionWarning", null); + }, + + @action + startReactionForMsgActions() { + this.chatEmojiPickerManager.startFromMessageActions( + this.message, + this.site.desktopView, + this.selectReaction + ); + }, + + @action + startReactionForReactionList() { + this.chatEmojiPickerManager.startFromMessageReactionList( + this.message, + this.site.desktopView, + this.selectReaction + ); + }, + + deselectReaction(emoji) { + if (!this.canInteractWithChat) { + return; + } + + this.react(emoji, this.REMOVE_REACTION); + this.notifyPropertyChange("emojiReactions"); + }, + + @action + selectReaction(emoji) { + if (!this.canInteractWithChat) { + return; + } + + this.react(emoji, this.ADD_REACTION); + this.notifyPropertyChange("emojiReactions"); + }, + + @bind + _handleReactionMessage(busData) { + const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji); + if (loadingReactionIndex > -1) { + return this._loadingReactions.splice(loadingReactionIndex, 1); + } + + this._updateReactionsList(busData.emoji, busData.action, busData.user); + this.afterReactionAdded(); + }, + + @action + react(emoji, reactAction) { + if (!this.canInteractWithChat || this._loadingReactions.includes(emoji)) { + return; + } + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + + if (this.site.mobileView) { + this.set("isHovered", false); + } + + this._loadingReactions.push(emoji); + this._updateReactionsList(emoji, reactAction, this.currentUser); + + if (reactAction === this.ADD_REACTION) { + this.chatEmojiReactionStore.track(`:${emoji}:`); + } + + return this._publishReaction(emoji, reactAction).then(() => { + this.notifyPropertyChange("emojiReactions"); + + // creating reaction will create a membership if not present + // so we will fully refresh if we were not members of the channel + // already + if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { + this.chat.forceRefreshChannels().then(() => { + return this.chat + .getChannelBy("id", this.chatChannel.id) + .then((reactedChannel) => { + this.onSwitchChannel(reactedChannel); + }); + }); + } + }); + }, + + _updateReactionsList(emoji, reactAction, user) { + const selfReacted = this.currentUser.id === user.id; + if (this.message.reactions[emoji]) { + if ( + selfReacted && + reactAction === this.ADD_REACTION && + this.message.reactions[emoji].reacted + ) { + // User is already has reaction added; do nothing + return false; + } + + let newCount = + reactAction === this.ADD_REACTION + ? this.message.reactions[emoji].count + 1 + : this.message.reactions[emoji].count - 1; + + this.message.reactions.set(`${emoji}.count`, newCount); + if (selfReacted) { + this.message.reactions.set( + `${emoji}.reacted`, + reactAction === this.ADD_REACTION + ); + } else { + this.message.reactions[emoji].users.pushObject(user); + } + } else { + if (reactAction === this.ADD_REACTION) { + this.message.reactions.set(emoji, { + count: 1, + reacted: selfReacted, + users: selfReacted ? [] : [user], + }); + } + } + this.message.notifyPropertyChange("reactions"); + }, + + _publishReaction(emoji, reactAction) { + return ajax( + `/chat/${this.details.chat_channel_id}/react/${this.message.id}`, + { + type: "PUT", + data: { + react_action: reactAction, + emoji, + }, + } + ).catch((errResult) => { + popupAjaxError(errResult); + this._updateReactionsList(emoji, this.REMOVE_REACTION, this.currentUser); + }); + }, + + // TODO(roman): For backwards-compatibility. + // Remove after the 3.0 release. + _legacyFlag() { + this.dialog.yesNoConfirm({ + message: I18n.t("chat.confirm_flag", { + username: this.message.user?.username, + }), + didConfirm: () => { + return ajax("/chat/flag", { + method: "PUT", + data: { + chat_message_id: this.message.id, + flag_type_id: 7, // notify_moderators + }, + }).catch(popupAjaxError); + }, + }); + }, + + @action + reply() { + this.setReplyTo(this.message.id); + }, + + @action + viewReply() { + this.replyMessageClicked(this.message.in_reply_to); + }, + + @action + edit() { + this.editButtonClicked(this.message.id); + }, + + @action + flag() { + const targetFlagSupported = + requirejs.entries["discourse/lib/flag-targets/flag"]; + + if (targetFlagSupported) { + const model = EmberObject.create(this.message); + model.set("username", model.get("user.username")); + model.set("user_id", model.get("user.id")); + let controller = showModal("flag", { model }); + + controller.setProperties({ flagTarget: new ChatMessageFlag() }); + } else { + this._legacyFlag(); + } + }, + + @action + silence() { + this.adminTools.showSilenceModal(EmberObject.create(this.message.user)); + }, + + @action + expand() { + this.message.set("expanded", true); + }, + + @action + restore() { + return ajax( + `/chat/${this.details.chat_channel_id}/restore/${this.message.id}`, + { + type: "PUT", + } + ).catch(popupAjaxError); + }, + + @action + toggleBookmark() { + return openBookmarkModal( + this.message.bookmark || + Bookmark.create({ + bookmarkable_type: "ChatMessage", + bookmarkable_id: this.message.id, + user_id: this.currentUser.id, + }), + { + onAfterSave: (savedData) => { + const bookmark = Bookmark.create(savedData); + this.set("message.bookmark", bookmark); + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + }, + onAfterDelete: () => { + this.set("message.bookmark", null); + }, + } + ); + }, + + @action + rebakeMessage() { + return ajax( + `/chat/${this.details.chat_channel_id}/${this.message.id}/rebake`, + { + type: "PUT", + } + ).catch(popupAjaxError); + }, + + @action + deleteMessage() { + return ajax(`/chat/${this.details.chat_channel_id}/${this.message.id}`, { + type: "DELETE", + }).catch(popupAjaxError); + }, + + @action + selectMessage() { + this.message.set("selected", true); + this.onStartSelectingMessages(this.message); + }, + + @action + @afterRender + toggleChecked(e) { + if (e.shiftKey) { + this.bulkSelectMessages(this.message, e.target.checked); + } + + this.onSelectMessage(this.message); + }, + + @action + copyLinkToMessage() { + if (!this.messageContainer) { + return; + } + + this.messageContainer + .querySelector(".link-to-message-btn") + ?.classList?.add("copied"); + + const { protocol, host } = window.location; + let url = getURL( + `/chat/channel/${this.details.chat_channel_id}/-?messageId=${this.message.id}` + ); + url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; + clipboardCopy(url); + + discourseLater(() => { + this.messageContainer + ?.querySelector(".link-to-message-btn") + ?.classList?.remove("copied"); + }, 250); + }, + + @computed + get emojiReactions() { + const favorites = this.cachedFavoritesReactions; + + // may be a {} if no defaults defined in some production builds + if (!favorites || !favorites.slice) { + return []; + } + + const userReactions = Object.keys(this.message.reactions).filter((key) => { + return this.message.reactions[key].reacted; + }); + + return favorites.slice(0, 3).map((emoji) => { + if (userReactions.includes(emoji)) { + return { emoji, reacted: true }; + } else { + return { emoji, reacted: false }; + } + }); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js new file mode 100644 index 00000000000..0e0f797e938 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-replying-indicator.js @@ -0,0 +1,85 @@ +import { isBlank, isPresent } from "@ember/utils"; +import Component from "@ember/component"; +import { inject as service } from "@ember/service"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { fmt } from "discourse/lib/computed"; +import { next } from "@ember/runloop"; + +export default Component.extend({ + tagName: "", + presence: service(), + presenceChannel: null, + chatChannel: null, + + @discourseComputed("presenceChannel.users.[]") + usernames(users) { + return users + ?.filter((u) => u.id !== this.currentUser.id) + ?.mapBy("username"); + }, + + @discourseComputed("usernames.[]") + text(usernames) { + if (isBlank(usernames)) { + return; + } + + if (usernames.length === 1) { + return I18n.t("chat.replying_indicator.single_user", { + username: usernames[0], + }); + } + + if (usernames.length < 4) { + const lastUsername = usernames.pop(); + const commaSeparatedUsernames = usernames.join(", "); + return I18n.t("chat.replying_indicator.multiple_users", { + commaSeparatedUsernames, + lastUsername, + }); + } + + const commaSeparatedUsernames = usernames.slice(0, 2).join(", "); + return I18n.t("chat.replying_indicator.many_users", { + commaSeparatedUsernames, + count: usernames.length - 2, + }); + }, + + @discourseComputed("usernames.[]") + shouldDisplay(usernames) { + return isPresent(usernames); + }, + + channelName: fmt("chatChannel.id", "/chat-reply/%@"), + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.chatChannel || this.chatChannel.isDraft) { + this.presenceChannel?.unsubscribe(); + return; + } + + if (this.presenceChannel?.name !== this.channelName) { + this.presenceChannel?.unsubscribe(); + + next(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + const presenceChannel = this.presence.getChannel(this.channelName); + this.set("presenceChannel", presenceChannel); + presenceChannel.subscribe(); + }); + } + }, + + willDestroyElement() { + this._super(...arguments); + + this.presenceChannel?.unsubscribe(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js new file mode 100644 index 00000000000..ec667667603 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-retention-reminder.js @@ -0,0 +1,59 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Component.extend({ + tagName: "", + loading: false, + + @discourseComputed( + "chatChannel.chatable_type", + "currentUser.{needs_dm_retention_reminder,needs_channel_retention_reminder}" + ) + show() { + return ( + !this.chatChannel.isDraft && + ((this.chatChannel.isDirectMessageChannel && + this.currentUser.needs_dm_retention_reminder) || + (!this.chatChannel.isDirectMessageChannel && + this.currentUser.needs_channel_retention_reminder)) + ); + }, + + @discourseComputed("chatChannel.chatable_type") + text() { + let days = this.siteSettings.chat_channel_retention_days; + let translationKey = "chat.retention_reminders.public"; + + if (this.chatChannel.isDirectMessageChannel) { + days = this.siteSettings.chat_dm_retention_days; + translationKey = "chat.retention_reminders.dm"; + } + return I18n.t(translationKey, { days }); + }, + + @discourseComputed("chatChannel.chatable_type") + daysCount() { + return this.chatChannel.isDirectMessageChannel + ? this.siteSettings.chat_dm_retention_days + : this.siteSettings.chat_channel_retention_days; + }, + + @action + dismiss() { + return ajax("/chat/dismiss-retention-reminder", { + method: "POST", + data: { chatable_type: this.chatChannel.chatable_type }, + }) + .then(() => { + const field = this.chatChannel.isDirectMessageChannel + ? "needs_dm_retention_reminder" + : "needs_channel_retention_reminder"; + this.currentUser.set(field, false); + }) + .catch(popupAjaxError); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js new file mode 100644 index 00000000000..a9c8887318d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js @@ -0,0 +1,124 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import showModal from "discourse/lib/show-modal"; +import { clipboardCopyAsync } from "discourse/lib/utilities"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { ajax } from "discourse/lib/ajax"; +import { isTesting } from "discourse-common/config/environment"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { schedule } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import getURL from "discourse-common/lib/get-url"; +import { bind } from "discourse-common/utils/decorators"; + +export default class AdminCustomizeColorsShowController extends Component { + @service router; + tagName = ""; + chatChannel = null; + selectedMessageIds = null; + showChatQuoteSuccess = false; + cancelSelecting = null; + canModerate = false; + + @computed("selectedMessageIds.length") + get anyMessagesSelected() { + return this.selectedMessageIds.length > 0; + } + + @computed("chatChannel.isDirectMessageChannel", "canModerate") + get showMoveMessageButton() { + return !this.chatChannel.isDirectMessageChannel && this.canModerate; + } + + @bind + async generateQuote() { + const response = await ajax( + getURL(`/chat/${this.chatChannel.id}/quote.json`), + { + data: { message_ids: this.selectedMessageIds }, + type: "POST", + } + ); + + return new Blob([response.markdown], { + type: "text/plain", + }); + } + + @action + openMoveMessageModal() { + showModal("chat-message-move-to-channel-modal").setProperties({ + sourceChannel: this.chatChannel, + selectedMessageIds: this.selectedMessageIds, + }); + } + + @action + async quoteMessages() { + let quoteMarkdown; + + try { + const quoteMarkdownBlob = await this.generateQuote(); + quoteMarkdown = await quoteMarkdownBlob.text(); + } catch (error) { + popupAjaxError(error); + } + + const container = getOwner(this); + const composer = container.lookup("controller:composer"); + const openOpts = {}; + + if (this.chatChannel.isCategoryChannel) { + openOpts.categoryId = this.chatChannel.chatable_id; + } + + if (this.site.mobileView) { + // go to the relevant chatable (e.g. category) and open the + // composer to insert text + if (this.chatChannel.chatable_url) { + this.router.transitionTo(this.chatChannel.chatable_url); + } + + await composer.focusComposer({ + fallbackToNewTopic: true, + insertText: quoteMarkdown, + openOpts, + }); + } else { + // open the composer and insert text, reply to the current + // topic if there is one, use the active draft if there is one + const topic = container.lookup("controller:topic"); + await composer.focusComposer({ + fallbackToNewTopic: true, + topic: topic?.model, + insertText: quoteMarkdown, + openOpts, + }); + } + } + + @action + async copyMessages() { + try { + if (!isTesting()) { + // clipboard API throws errors in tests + await clipboardCopyAsync(this.generateQuote); + } + + this.set("showChatQuoteSuccess", true); + + schedule("afterRender", () => { + const element = document.querySelector(".chat-selection-message"); + element?.addEventListener("animationend", () => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("showChatQuoteSuccess", false); + }); + }); + } catch (error) { + popupAjaxError(error); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js b/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js new file mode 100644 index 00000000000..e7ae0190ffc --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-to-topic-selector.js @@ -0,0 +1,44 @@ +import Component from "@ember/component"; +import { htmlSafe } from "@ember/template"; +import discourseComputed from "discourse-common/utils/decorators"; +import { alias, equal } from "@ember/object/computed"; + +export const NEW_TOPIC_SELECTION = "newTopic"; +export const EXISTING_TOPIC_SELECTION = "existingTopic"; +export const NEW_MESSAGE_SELECTION = "newMessage"; + +export default Component.extend({ + newTopicSelection: NEW_TOPIC_SELECTION, + existingTopicSelection: EXISTING_TOPIC_SELECTION, + newMessageSelection: NEW_MESSAGE_SELECTION, + + selection: null, + newTopic: equal("selection", NEW_TOPIC_SELECTION), + existingTopic: equal("selection", EXISTING_TOPIC_SELECTION), + newMessage: equal("selection", NEW_MESSAGE_SELECTION), + canAddTags: alias("site.can_create_tag"), + canTagMessages: alias("site.can_tag_pms"), + + topicTitle: null, + categoryId: null, + tags: null, + selectedTopicId: null, + + chatMessageIds: null, + chatChannelId: null, + + @discourseComputed() + newTopicInstruction() { + return htmlSafe(this.instructionLabels[NEW_TOPIC_SELECTION]); + }, + + @discourseComputed() + existingTopicInstruction() { + return htmlSafe(this.instructionLabels[EXISTING_TOPIC_SELECTION]); + }, + + @discourseComputed() + newMessageInstruction() { + return htmlSafe(this.instructionLabels[NEW_MESSAGE_SELECTION]); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-upload.js new file mode 100644 index 00000000000..e81190ed12d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload.js @@ -0,0 +1,51 @@ +import Component from "@glimmer/component"; + +import { inject as service } from "@ember/service"; +import { isImage, isVideo } from "discourse/lib/uploads"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; +import { htmlSafe } from "@ember/template"; + +export default class extends Component { + @service siteSettings; + + @tracked loaded = false; + + IMAGE_TYPE = "image"; + VIDEO_TYPE = "video"; + ATTACHMENT_TYPE = "attachment"; + + get type() { + if (isImage(this.args.upload.original_filename)) { + return this.IMAGE_TYPE; + } + + if (isVideo(this.args.upload.original_filename)) { + return this.VIDEO_TYPE; + } + + return this.ATTACHMENT_TYPE; + } + + get size() { + const width = this.args.upload.width; + const height = this.args.upload.height; + + const ratio = Math.min( + this.siteSettings.max_image_width / width, + this.siteSettings.max_image_height / height + ); + return { width: width * ratio, height: height * ratio }; + } + + get imageStyle() { + if (this.args.upload.dominant_color && !this.loaded) { + return htmlSafe(`background-color: #${this.args.upload.dominant_color};`); + } + } + + @action + imageLoaded() { + this.loaded = true; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js new file mode 100644 index 00000000000..0b3853e915e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-avatar.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatUserAvatar extends Component { + @service chat; + tagName = ""; + + user = null; + + avatarSize = "tiny"; + + @computed("chat.presenceChannel.users.[]", "user.{id,username}") + get isOnline() { + const users = this.chat.presenceChannel?.users; + + return ( + !!users?.findBy("id", this.user?.id) || + !!users?.findBy("username", this.user?.username) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js new file mode 100644 index 00000000000..3130f9d6de6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-user-display-name.js @@ -0,0 +1,33 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { formatUsername } from "discourse/lib/utilities"; + +export default class ChatUserDisplayName extends Component { + tagName = ""; + user = null; + + @computed + get shouldPrioritizeNameInUx() { + return !this.siteSettings.prioritize_username_in_ux; + } + + @computed("user.name") + get hasValidName() { + return this.user?.name && this.user?.name.trim().length > 0; + } + + @computed("user.username") + get formattedUsername() { + return formatUsername(this.user?.username); + } + + @computed("shouldPrioritizeNameInUx", "hasValidName") + get shouldShowNameFirst() { + return this.shouldPrioritizeNameInUx && this.hasValidName; + } + + @computed("shouldPrioritizeNameInUx", "hasValidName") + get shouldShowNameLast() { + return !this.shouldPrioritizeNameInUx && this.hasValidName; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js new file mode 100644 index 00000000000..270e4a3bc95 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js @@ -0,0 +1,97 @@ +import { bind } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; + +const CSS_VAR = "--chat-vh"; +let pendingUpdate = false; + +export default class ChatVh extends Component { + tagName = ""; + + didInsertElement() { + this._super(...arguments); + + this.setVHFromVisualViewPort(); + + (window?.visualViewport || window).addEventListener( + "resize", + this.setVHFromVisualViewPort + ); + + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.addEventListener( + "geometrychange", + this.setVHFromKeyboard + ); + } + } + + willDestroyElement() { + this._super(...arguments); + + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.removeEventListener( + "geometrychange", + this.setVHFromKeyboard + ); + } else { + (window?.visualViewport || window).removeEventListener( + "resize", + this.setVHFromVisualViewPort + ); + } + + pendingUpdate = false; + } + + @bind + setVHFromKeyboard(event) { + if (pendingUpdate) { + return; + } + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (isZoomed()) { + return; + } + + pendingUpdate = true; + + requestAnimationFrame(() => { + const { height } = event.target.boundingRect; + const vhInPixels = + ((window.visualViewport?.height || window.innerHeight) - height) * 0.01; + document.documentElement.style.setProperty(CSS_VAR, `${vhInPixels}px`); + + pendingUpdate = false; + }); + } + + @bind + setVHFromVisualViewPort() { + if (pendingUpdate) { + return; + } + + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (isZoomed()) { + return; + } + + pendingUpdate = true; + + requestAnimationFrame(() => { + const vhInPixels = + (window.visualViewport?.height || window.innerHeight) * 0.01; + document.documentElement.style.setProperty(CSS_VAR, `${vhInPixels}px`); + + pendingUpdate = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/collapser.js b/plugins/chat/assets/javascripts/discourse/components/collapser.js new file mode 100644 index 00000000000..f7d05cf8376 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/collapser.js @@ -0,0 +1,19 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + collapsed: false, + header: null, + + @action + open() { + this.set("collapsed", false); + }, + + @action + close() { + this.set("collapsed", true); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js new file mode 100644 index 00000000000..4889d541a17 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/d-progress-bar.js @@ -0,0 +1,111 @@ +// temporary stuff to be moved in core with discourse-loading-slider + +import Component from "@ember/component"; +import { cancel, schedule } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; + +const STORE_LOADING_TIMES = 5; +const DEFAULT_LOADING_TIME = 0.3; +const MIN_LOADING_TIME = 0.1; +const STILL_LOADING_DURATION = 2; + +export default Component.extend({ + tagName: "", + isLoading: false, + key: null, + + init() { + this._super(...arguments); + + this.loadingTimes = [DEFAULT_LOADING_TIME]; + this.set("averageTime", DEFAULT_LOADING_TIME); + this.i = 0; + this.scheduled = []; + }, + + resetState() { + this.container?.classList?.remove("done", "loading", "still-loading"); + }, + + cancelScheduled() { + this.scheduled.forEach((s) => cancel(s)); + this.scheduled = []; + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (!this.key) { + return; + } + + this.cancelScheduled(); + this.resetState(); + + if (this.isLoading) { + this.start(); + } else { + this.end(); + } + }, + + get container() { + return document.getElementById(this.key); + }, + + start() { + this.set("startedAt", Date.now()); + + this.scheduled.push(discourseLater(this, "startLoading")); + this.scheduled.push( + discourseLater(this, "stillLoading", STILL_LOADING_DURATION * 1000) + ); + }, + + startLoading() { + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.add("loading"); + document.documentElement.style.setProperty( + "--loading-duration", + `${this.averageTime.toFixed(2)}s` + ); + }) + ); + }, + + stillLoading() { + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.add("still-loading"); + }) + ); + }, + + end() { + this.updateAverage((Date.now() - this.startedAt) / 1000); + + this.cancelScheduled(); + + this.scheduled.push( + schedule("afterRender", () => { + this.container?.classList?.remove("loading", "still-loading"); + this.container?.classList?.add("done"); + }) + ); + }, + + updateAverage(durationSeconds) { + if (durationSeconds < MIN_LOADING_TIME) { + durationSeconds = MIN_LOADING_TIME; + } + + this.loadingTimes[this.i] = durationSeconds; + + this.i = (this.i + 1) % STORE_LOADING_TIMES; + this.set( + "averageTime", + this.loadingTimes.reduce((p, c) => p + c, 0) / this.loadingTimes.length + ); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js new file mode 100644 index 00000000000..60b7b82ea4f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/dc-filter-input.js @@ -0,0 +1,3 @@ +import Component from "@glimmer/component"; + +export default class DcFilterInput extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js new file mode 100644 index 00000000000..426029db34e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js @@ -0,0 +1,315 @@ +import { caretPosition } from "discourse/lib/utilities"; +import { isEmpty } from "@ember/utils"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import discourseDebounce from "discourse-common/lib/debounce"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; +import { INPUT_DELAY } from "discourse-common/config/environment"; +import { inject as service } from "@ember/service"; +import { schedule } from "@ember/runloop"; +import { gt, not } from "@ember/object/computed"; +import { createDirectMessageChannelDraft } from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default Component.extend({ + tagName: "", + users: null, + selectedUsers: null, + term: null, + isFiltering: false, + isFilterFocused: false, + highlightedSelectedUser: null, + focusedUser: null, + chat: service(), + router: service(), + isLoading: false, + onSwitchChannel: null, + + init() { + this._super(...arguments); + + this.set("users", []); + this.set("selectedUsers", []); + this.set("channel", createDirectMessageChannelDraft()); + }, + + didInsertElement() { + this._super(...arguments); + + this.filterUsernames(); + }, + + didReceiveAttrs() { + this._super(...arguments); + + this.set("term", null); + + this.focusFilter(); + + if (!this.hasSelection) { + this.filterUsernames(); + } + }, + + hasSelection: gt("channel.chatable.users.length", 0), + + @discourseComputed + chatProgressBarContainer() { + return document.querySelector("#chat-progress-bar-container"); + }, + + @bind + filterUsernames(term = null) { + this.set("isFiltering", true); + + this.chat + .searchPossibleDirectMessageUsers({ + term, + limit: 6, + exclude: this.channel.chatable?.users?.mapBy("username") || [], + lastSeenUsers: isEmpty(term) ? true : false, + }) + .then((r) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (r !== "__CANCELLED") { + this.set("users", r.users || []); + this.set("focusedUser", this.users.firstObject); + } + }) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isFiltering", false); + }); + }, + + shouldRenderResults: not("isFiltering"), + + @action + selectUser(user) { + this.selectedUsers.pushObject(user); + this.users.removeObject(user); + this.set("users", []); + this.set("focusedUser", null); + this.set("highlightedSelectedUser", null); + this.set("term", null); + this.focusFilter(); + this.onChangeSelectedUsers?.(this.selectedUsers); + }, + + @action + deselectUser(user) { + this.users.removeObject(user); + this.selectedUsers.removeObject(user); + this.set("focusedUser", this.users.firstObject); + this.set("highlightedSelectedUser", null); + this.set("term", null); + + if (isEmpty(this.selectedUsers)) { + this.filterUsernames(); + } + + this.focusFilter(); + this.onChangeSelectedUsers?.(this.selectedUsers); + }, + + @action + focusFilter() { + this.set("isFilterFocused", true); + + schedule("afterRender", () => { + document.querySelector(".filter-usernames")?.focus(); + }); + }, + + @action + onFilterInput(term) { + this.set("term", term); + this.set("users", []); + + if (!term?.length) { + return; + } + + this.set("isFiltering", true); + + discourseDebounce(this, this.filterUsernames, term, INPUT_DELAY); + }, + + @action + handleUserKeyUp(user, event) { + if (event.key === "Enter") { + event.stopPropagation(); + event.preventDefault(); + this.selectUser(user); + } + }, + + @action + onFilterInputFocusOut() { + this.set("isFilterFocused", false); + this.set("highlightedSelectedUser", null); + }, + + @action + leaveChannel() { + this.router.transitionTo("chat.index"); + }, + + @action + handleFilterKeyUp(event) { + if (event.key === "Tab") { + const enabledComposer = document.querySelector(".chat-composer-input"); + if (enabledComposer && !enabledComposer.disabled) { + event.preventDefault(); + event.stopPropagation(); + enabledComposer.focus(); + } + } + + if ( + (event.key === "Enter" || event.key === "Backspace") && + this.highlightedSelectedUser + ) { + event.preventDefault(); + event.stopPropagation(); + this.deselectUser(this.highlightedSelectedUser); + return; + } + + if (event.key === "Backspace" && isEmpty(this.term) && this.hasSelection) { + event.preventDefault(); + event.stopPropagation(); + + this.deselectUser(this.channel.chatable.users.lastObject); + } + + if (event.key === "Enter" && this.focusedUser) { + event.preventDefault(); + event.stopPropagation(); + this.selectUser(this.focusedUser); + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + this._handleVerticalArrowKeys(event); + } + + if (event.key === "Escape" && this.highlightedSelectedUser) { + this.set("highlightedSelectedUser", null); + } + + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + this._handleHorizontalArrowKeys(event); + } + }, + + _firstSelectWithArrows(event) { + if (event.key === "ArrowRight") { + return; + } + + if (event.key === "ArrowLeft") { + const position = caretPosition( + document.querySelector(".filter-usernames") + ); + if (position > 0) { + return; + } else { + event.preventDefault(); + event.stopPropagation(); + this.set( + "highlightedSelectedUser", + this.channel.chatable.users.lastObject + ); + } + } + }, + + _changeSelectionWithArrows(event) { + if (event.key === "ArrowRight") { + if ( + this.highlightedSelectedUser === this.channel.chatable.users.lastObject + ) { + this.set("highlightedSelectedUser", null); + return; + } + + if (this.channel.chatable.users.length === 1) { + return; + } + + this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1); + } + + if (event.key === "ArrowLeft") { + if (this.channel.chatable.users.length === 1) { + return; + } + + this._highlightNextSelectedUser(event.key === "ArrowLeft" ? -1 : 1); + } + }, + + _highlightNextSelectedUser(modifier) { + const newIndex = + this.channel.chatable.users.indexOf(this.highlightedSelectedUser) + + modifier; + + if (this.channel.chatable.users.objectAt(newIndex)) { + this.set( + "highlightedSelectedUser", + this.channel.chatable.users.objectAt(newIndex) + ); + } else { + this.set( + "highlightedSelectedUser", + event.key === "ArrowLeft" + ? this.channel.chatable.users.lastObject + : this.channel.chatable.users.firstObject + ); + } + }, + + _handleHorizontalArrowKeys(event) { + const position = caretPosition(document.querySelector(".filter-usernames")); + if (position > 0) { + return; + } + + if (!this.highlightedSelectedUser) { + this._firstSelectWithArrows(event); + } else { + this._changeSelectionWithArrows(event); + } + }, + + _handleVerticalArrowKeys(event) { + if (isEmpty(this.users)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!this.focusedUser) { + this.set("focusedUser", this.users.firstObject); + return; + } + + const modifier = event.key === "ArrowUp" ? -1 : 1; + const newIndex = this.users.indexOf(this.focusedUser) + modifier; + + if (this.users.objectAt(newIndex)) { + this.set("focusedUser", this.users.objectAt(newIndex)); + } else { + this.set( + "focusedUser", + event.key === "ArrowUp" ? this.users.lastObject : this.users.firstObject + ); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js new file mode 100644 index 00000000000..76edf0f21ee --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.js @@ -0,0 +1,95 @@ +import Component from "@ember/component"; +import { bind } from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + tagName: "", + router: service(), + chat: service(), + + init() { + this._super(...arguments); + + this.appEvents.on("chat:refresh-channels", this, "refreshModel"); + this.appEvents.on("chat:refresh-channel", this, "_refreshChannel"); + }, + + didInsertElement() { + this._super(...arguments); + + this._scrollSidebarToBottom(); + document.addEventListener("keydown", this._autoFocusChatComposer); + }, + + willDestroyElement() { + this._super(...arguments); + + this.appEvents.off("chat:refresh-channels", this, "refreshModel"); + this.appEvents.off("chat:refresh-channel", this, "_refreshChannel"); + document.removeEventListener("keydown", this._autoFocusChatComposer); + }, + + @bind + _autoFocusChatComposer(event) { + if ( + !event.key || + // Handles things like Enter, Tab, Shift + event.key.length > 1 || + // Don't need to focus if the user is beginning a shortcut. + event.metaKey || + event.ctrlKey || + // Space's key comes through as ' ' so it's not covered by event.key + event.code === "Space" || + // ? is used for the keyboard shortcut modal + event.key === "?" + ) { + return; + } + + if ( + !event.target || + /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName) + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const composer = document.querySelector(".chat-composer-input"); + if (composer && !this.chat.activeChannel.isDraft) { + this.appEvents.trigger("chat:insert-text", event.key); + composer.focus(); + } + }, + + _scrollSidebarToBottom() { + if (!this.teamsSidebarOn) { + return; + } + + const sidebarScroll = document.querySelector( + ".sidebar-container .scroll-wrapper" + ); + if (sidebarScroll) { + sidebarScroll.scrollTop = sidebarScroll.scrollHeight; + } + }, + + _refreshChannel(channelId) { + if (this.chat.activeChannel?.id === channelId) { + this.refreshModel(true); + } + }, + + @action + navigateToIndex() { + this.router.transitionTo("chat.index"); + }, + + @action + switchChannel(channel) { + return this.chat.openChannel(channel); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js new file mode 100644 index 00000000000..1cda1aaf901 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/on-visibility-action.js @@ -0,0 +1,48 @@ +import Component from "@ember/component"; +import { computed } from "@ember/object"; +import { bind } from "discourse-common/utils/decorators"; +import { guidFor } from "@ember/object/internals"; + +export default class OnVisibilityAction extends Component { + action = null; + + root = document.body; + + @computed + get onVisibilityActionId() { + return "on-visibility-action-" + guidFor(this); + } + + _element() { + return document.getElementById(this.onVisibilityActionId); + } + + didInsertElement() { + this._super(...arguments); + + let options = { + root: this.root, + rootMargin: "0px", + threshold: 1.0, + }; + + this._observer = new IntersectionObserver(this._handleIntersect, options); + this._observer.observe(this._element()); + } + + willDestroyElement() { + this._super(...arguments); + + this._observer?.disconnect(); + this.root = null; + } + + @bind + _handleIntersect(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.action?.(); + } + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js new file mode 100644 index 00000000000..68884a01910 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/sidebar-channels.js @@ -0,0 +1,20 @@ +import Component from "@ember/component"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class SidebarChannels extends Component { + @service chat; + @service router; + tagName = ""; + toggleSection = null; + + @computed("chat.userCanChat") + get isDisplayed() { + return this.chat.userCanChat; + } + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js b/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js new file mode 100644 index 00000000000..866e85c26b4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/toggle-channel-membership-button.js @@ -0,0 +1,94 @@ +import Component from "@ember/component"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action, computed } from "@ember/object"; + +export default class ToggleChannelMembershipButton extends Component { + @service chat; + + tagName = ""; + channel = null; + onToggle = null; + options = null; + isLoading = false; + + init() { + super.init(...arguments); + + this.set( + "options", + Object.assign( + { + labelType: "normal", + joinTitle: I18n.t("chat.channel_settings.join_channel"), + joinIcon: "", + joinClass: "", + leaveTitle: I18n.t("chat.channel_settings.leave_channel"), + leaveIcon: "", + leaveClass: "", + }, + this.options || {} + ) + ); + } + + @computed("channel.current_user_membership.following") + get label() { + if (this.options.labelType === "none") { + return ""; + } + + if (this.options.labelType === "short") { + if (this.channel.isFollowing) { + return I18n.t("chat.channel_settings.leave"); + } else { + return I18n.t("chat.channel_settings.join"); + } + } + + if (this.channel.isFollowing) { + return I18n.t("chat.channel_settings.leave_channel"); + } else { + return I18n.t("chat.channel_settings.join_channel"); + } + } + + @action + onJoinChannel() { + this.set("isLoading", true); + + return this.chat + .followChannel(this.channel) + .then(() => { + this.onToggle?.(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isLoading", false); + }); + } + + @action + onLeaveChannel() { + this.set("isLoading", true); + + return this.chat + .unfollowChannel(this.channel) + .then(() => { + this.onToggle?.(); + }) + .catch(popupAjaxError) + .finally(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isLoading", false); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js b/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js new file mode 100644 index 00000000000..17ae2dac5fb --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/topic-chat-float.js @@ -0,0 +1,351 @@ +import Component from "@ember/component"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import getURL from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import { + CHAT_VIEW, + DRAFT_CHANNEL_VIEW, + LIST_VIEW, +} from "discourse/plugins/chat/discourse/services/chat"; +import { equal } from "@ember/object/computed"; +import { cancel, next, throttle } from "@ember/runloop"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + listView: equal("view", LIST_VIEW), + chatView: equal("view", CHAT_VIEW), + draftChannelView: equal("view", DRAFT_CHANNEL_VIEW), + classNameBindings: [":topic-chat-float-container", "hidden"], + chat: service(), + router: service(), + fullPageChat: service(), + hidden: true, + loading: false, + expanded: true, // TODO - false when not first-load topic + showClose: true, // TODO - false when on same topic + sizeTimer: null, + rafTimer: null, + view: null, + hasUnreadMessages: false, + + didInsertElement() { + this._super(...arguments); + if (!this.chat.userCanChat) { + return; + } + + this._checkSize(); + this.appEvents.on("chat:navigated-to-full-page", this, "close"); + this.appEvents.on("chat:open-view", this, "openView"); + this.appEvents.on("chat:toggle-open", this, "toggleChat"); + this.appEvents.on("chat:toggle-close", this, "close"); + this.appEvents.on( + "chat:open-channel-for-chatable", + this, + "openChannelForChatable" + ); + this.appEvents.on("chat:open-channel", this, "switchChannel"); + this.appEvents.on( + "chat:open-channel-at-message", + this, + "openChannelAtMessage" + ); + this.appEvents.on("chat:refresh-channels", this, "refreshChannels"); + this.appEvents.on("composer:closed", this, "_checkSize"); + this.appEvents.on("composer:opened", this, "_checkSize"); + this.appEvents.on("composer:resized", this, "_checkSize"); + this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize"); + this.appEvents.on( + "composer:resize-started", + this, + "_startDynamicCheckSize" + ); + this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize"); + }, + + willDestroyElement() { + this._super(...arguments); + if (!this.chat.userCanChat) { + return; + } + + if (this.appEvents) { + this.appEvents.off("chat:open-view", this, "openView"); + this.appEvents.off("chat:navigated-to-full-page", this, "close"); + this.appEvents.off("chat:toggle-open", this, "toggleChat"); + this.appEvents.off("chat:toggle-close", this, "close"); + this.appEvents.off( + "chat:open-channel-for-chatable", + this, + "openChannelForChatable" + ); + this.appEvents.off("chat:open-channel", this, "switchChannel"); + this.appEvents.off( + "chat:open-channel-at-message", + this, + "openChannelAtMessage" + ); + this.appEvents.off("chat:refresh-channels", this, "refreshChannels"); + this.appEvents.off("composer:closed", this, "_checkSize"); + this.appEvents.off("composer:opened", this, "_checkSize"); + this.appEvents.off("composer:resized", this, "_checkSize"); + this.appEvents.off("composer:div-resizing", this, "_dynamicCheckSize"); + this.appEvents.off( + "composer:resize-started", + this, + "_startDynamicCheckSize" + ); + this.appEvents.off( + "composer:resize-ended", + this, + "_clearDynamicCheckSize" + ); + } + if (this.sizeTimer) { + cancel(this.sizeTimer); + this.sizeTimer = null; + } + if (this.rafTimer) { + window.cancelAnimationFrame(this.rafTimer); + } + }, + + @observes("hidden") + _fireHiddenAppEvents() { + this.chat.set("chatOpen", !this.hidden); + this.appEvents.trigger("chat:rerender-header"); + }, + + async openChannelForChatable(channel) { + if (!channel) { + return; + } + + this.switchChannel(channel); + }, + + @discourseComputed("expanded") + topLineClass(expanded) { + const baseClass = "topic-chat-drawer-header__top-line"; + return expanded ? `${baseClass}--expanded` : `${baseClass}--collapsed`; + }, + + @discourseComputed("expanded", "chat.activeChannel") + displayMembers(expanded, channel) { + return expanded && !channel?.isDirectMessageChannel; + }, + + @discourseComputed("displayMembers") + infoTabRoute(displayMembers) { + if (displayMembers) { + return "chat.channel.info.members"; + } + + return "chat.channel.info.settings"; + }, + + openChannelAtMessage(channel, messageId) { + this.chat.openChannel(channel, messageId); + }, + + _dynamicCheckSize() { + if (!this.rafTimer) { + this.rafTimer = window.requestAnimationFrame(() => { + this.rafTimer = null; + this._performCheckSize(); + }); + } + }, + + _startDynamicCheckSize() { + this.element.classList.add("clear-transitions"); + }, + + _clearDynamicCheckSize() { + this.element.classList.remove("clear-transitions"); + this._checkSize(); + }, + + _checkSize() { + this.sizeTimer = throttle(this, this._performCheckSize, 150); + }, + + _performCheckSize() { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + + const composer = document.getElementById("reply-control"); + const composerIsClosed = composer.classList.contains("closed"); + const minRightMargin = 15; + this.element.style.setProperty( + "--composer-right", + (composerIsClosed + ? minRightMargin + : Math.max(minRightMargin, composer.offsetLeft)) + "px" + ); + }, + + @discourseComputed( + "hidden", + "expanded", + "displayMembers", + "chat.activeChannel", + "chatView" + ) + containerClassNames(hidden, expanded, displayMembers, activeChannel) { + const classNames = ["topic-chat-container"]; + if (expanded) { + classNames.push("expanded"); + } + if (!hidden && expanded) { + classNames.push("visible"); + } + if (activeChannel) { + classNames.push(`channel-${activeChannel.id}`); + } + return classNames.join(" "); + }, + + @discourseComputed("expanded") + expandIcon(expanded) { + if (expanded) { + return "angle-double-down"; + } else { + return "angle-double-up"; + } + }, + + @discourseComputed( + "chat.activeChannel", + "currentUser.chat_channel_tracking_state" + ) + unreadCount(activeChannel, trackingState) { + return trackingState[activeChannel.id]?.unread_count || 0; + }, + + @action + openView(view) { + this.setProperties({ + hidden: false, + expanded: true, + view, + }); + + this.appEvents.trigger("chat:float-toggled", false); + }, + + @action + openInFullPage(e) { + const channel = this.chat.activeChannel; + + this.set("expanded", false); + this.set("hidden", true); + this.chat.setActiveChannel(null); + this.fullPageChat.isPreferred = true; + + if (!channel) { + return this.router.transitionTo("chat"); + } + + if (e.which === 2) { + // Middle mouse click + window + .open(getURL(`/chat/channel/${channel.id}/${channel.title}`), "_blank") + .focus(); + return false; + } + + this.chat.openChannel(channel); + }, + + @action + toggleExpand() { + this.set("expanded", !this.expanded); + this.appEvents.trigger("chat:toggle-expand", this.expanded); + }, + + @action + close() { + this.setProperties({ + hidden: true, + expanded: false, + }); + this.chat.setActiveChannel(null); + this.appEvents.trigger("chat:float-toggled", this.hidden); + }, + + @action + toggleChat() { + this.set("hidden", !this.hidden); + this.appEvents.trigger("chat:float-toggled", this.hidden); + if (this.hidden) { + return this.chat.setActiveChannel(null); + } else { + this.set("expanded", true); + this.appEvents.trigger("chat:toggle-expand", this.expanded); + if (this.chat.activeChannel) { + // Channel was previously open, so after expand we are done. + return this.chat.setActiveChannel(null); + } + } + + // Look for DM channel with unread, and fallback to public channel with unread + this.chat.getIdealFirstChannelId().then((channelId) => { + if (channelId) { + this.chat.getChannelBy("id", channelId).then((channel) => { + this.switchChannel(channel); + }); + } else { + // No channels with unread messages. Fetch channel index. + this.fetchChannels(); + } + }); + }, + + @action + refreshChannels() { + if (this.view === LIST_VIEW) { + this.fetchChannels(); + } + }, + + @action + fetchChannels() { + this.set("loading", true); + + this.chat.getChannels().then(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.setProperties({ + loading: false, + expanded: true, + view: LIST_VIEW, + }); + + this.chat.setActiveChannel(null); + }); + }, + + @action + switchChannel(channel) { + // we need next here to ensure we correctly let the time for routes transitions + // eg: deactivate hook of full page chat routes will set activeChannel to null + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.chat.setActiveChannel(channel); + + if (!channel) { + this.openView(LIST_VIEW); + return; + } + + this.openView(CHAT_VIEW); + }); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js b/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js new file mode 100644 index 00000000000..8d9f7d40ca3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-card-chat-button.js @@ -0,0 +1,17 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class UserCardChatButton extends Component { + @service chat; + + @action + startChatting() { + this.chat + .upsertDmChannelForUsernames([this.user.username]) + .then((chatChannel) => { + this.chat.openChannel(chatChannel); + this.appEvents.trigger("card:close"); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js new file mode 100644 index 00000000000..742bb3c18ef --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list-empty-state.js @@ -0,0 +1,3 @@ +import templateOnly from "@ember/component/template-only"; + +export default templateOnly(); diff --git a/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js new file mode 100644 index 00000000000..5190e0d579e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/user-menu/chat-notifications-list.js @@ -0,0 +1,11 @@ +import UserMenuNotificationsList from "discourse/components/user-menu/notifications-list"; + +export default class UserMenuChatNotificationsList extends UserMenuNotificationsList { + get dismissTypes() { + return this.filterByTypes; + } + + get emptyStateComponent() { + return "user-menu/chat-notifications-list-empty-state"; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs new file mode 100644 index 00000000000..d933bf21e5e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/topic-chat-outlet.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs b/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs new file mode 100644 index 00000000000..8ea2da698bf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/sidebar-bottom/sidebar-connector.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs b/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs new file mode 100644 index 00000000000..c01ace03aea --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/user-card-below-message-button/chat-button.hbs @@ -0,0 +1,3 @@ +{{#if this.user.can_chat_user}} + +{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs b/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs new file mode 100644 index 00000000000..954bade2659 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/user-preferences-nav/preferences-chat-link.hbs @@ -0,0 +1,5 @@ +{{#if this.model.can_chat}} + + {{i18n "chat.title_capitalized"}} + +{{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js b/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js new file mode 100644 index 00000000000..3d74162136d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/admin-plugins-chat.js @@ -0,0 +1,135 @@ +import Controller from "@ember/controller"; +import EmberObject, { action, computed } from "@ember/object"; +import I18n from "I18n"; +import { and } from "@ember/object/computed"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { inject as service } from "@ember/service"; + +export default class AdminPluginsChatController extends Controller { + @service dialog; + queryParams = { selectedWebhookId: "id" }; + + loading = false; + creatingNew = false; + newWebhookName = ""; + newWebhookChannelId = null; + emojiPickerIsActive = false; + + @and("newWebhookName", "newWebhookChannelId") nameAndChannelValid; + + @computed("model.incoming_chat_webhooks.@each.updated_at") + get sortedWebhooks() { + return ( + this.model.incoming_chat_webhooks?.sortBy("updated_at").reverse() || [] + ); + } + + @computed("selectedWebhookId") + get selectedWebhook() { + if (!this.selectedWebhookId) { + return; + } + + const id = parseInt(this.selectedWebhookId, 10); + return this.model.incoming_chat_webhooks.findBy("id", id); + } + + @computed("selectedWebhook.name", "selectedWebhook.chat_channel.id") + get saveEditDisabled() { + return !this.selectedWebhook.name || !this.selectedWebhook.chat_channel.id; + } + + @action + createNewWebhook() { + if (this.loading) { + return; + } + + this.set("loading", true); + const data = { + name: this.newWebhookName, + chat_channel_id: this.newWebhookChannelId, + }; + + return ajax("/admin/plugins/chat/hooks", { data, type: "POST" }) + .then((webhook) => { + const newWebhook = EmberObject.create(webhook); + this.set( + "model.incoming_chat_webhooks", + [newWebhook].concat(this.model.incoming_chat_webhooks) + ); + this.resetNewWebhook(); + this.setProperties({ + loading: false, + selectedWebhookId: newWebhook.id, + }); + }) + .catch(popupAjaxError); + } + + @action + resetNewWebhook() { + this.setProperties({ + creatingNew: false, + newWebhookName: "", + newWebhookChannelId: null, + }); + } + + @action + destroyWebhook(webhook) { + this.dialog.deleteConfirm({ + message: I18n.t("chat.incoming_webhooks.confirm_destroy"), + didConfirm: () => { + this.set("loading", true); + return ajax(`/admin/plugins/chat/hooks/${webhook.id}`, { + type: "DELETE", + }) + .then(() => { + this.model.incoming_chat_webhooks.removeObject(webhook); + this.set("loading", false); + }) + .catch(popupAjaxError); + }, + }); + } + + @action + emojiSelected(emoji) { + this.selectedWebhook.set("emoji", `:${emoji}:`); + return this.set("emojiPickerIsActive", false); + } + + @action + saveEdit() { + this.set("loading", true); + const data = { + name: this.selectedWebhook.name, + chat_channel_id: this.selectedWebhook.chat_channel.id, + description: this.selectedWebhook.description, + emoji: this.selectedWebhook.emoji, + username: this.selectedWebhook.username, + }; + return ajax(`/admin/plugins/chat/hooks/${this.selectedWebhook.id}`, { + data, + type: "PUT", + }) + .then(() => { + this.selectedWebhook.set("updated_at", new Date()); + this.setProperties({ + loading: false, + selectedWebhookId: null, + }); + }) + .catch(popupAjaxError); + } + + @action + changeChatChannel(chatChannelId) { + this.selectedWebhook.set( + "chat_channel", + this.model.chat_channels.findBy("id", chatChannelId) + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js new file mode 100644 index 00000000000..d46a9f241d0 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-archive-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatChannelArchiveModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js new file mode 100644 index 00000000000..ad230c1021b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-delete-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatChannelDeleteModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js new file mode 100644 index 00000000000..0efdb70bbf1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-description.js @@ -0,0 +1,49 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; + +export default class ChatChannelEditDescriptionController extends Controller.extend( + ModalFunctionality +) { + editedDescription = ""; + + @computed("model.description", "editedDescription") + get isSaveDisabled() { + return ( + this.model.description === this.editedDescription || + this.editedDescription?.length > 280 + ); + } + + onShow() { + this.set("editedDescription", this.model.description || ""); + } + + onClose() { + this.set("editedDescription", ""); + this.clearFlash(); + } + + @action + onSaveChatChannelDescription() { + return ChatApi.modifyChatChannel(this.model.id, { + description: this.editedDescription, + }) + .then((chatChannel) => { + this.model.set("description", chatChannel.description); + this.send("closeModal"); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + onChangeChatChannelDescription(description) { + this.clearFlash(); + this.set("editedDescription", description); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js new file mode 100644 index 00000000000..9eb3dbde1f5 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js @@ -0,0 +1,49 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; + +export default class ChatChannelEditTitleController extends Controller.extend( + ModalFunctionality +) { + editedTitle = ""; + + @computed("model.title", "editedTitle") + get isSaveDisabled() { + return ( + this.model.title === this.editedTitle || + this.editedTitle?.length > this.siteSettings.max_topic_title_length + ); + } + + onShow() { + this.set("editedTitle", this.model.title || ""); + } + + onClose() { + this.set("editedTitle", ""); + this.clearFlash(); + } + + @action + onSaveChatChannelTitle() { + return ChatApi.modifyChatChannel(this.model.id, { + name: this.editedTitle, + }) + .then((chatChannel) => { + this.model.set("title", chatChannel.title); + this.send("closeModal"); + }) + .catch((event) => { + if (event.jqXHR?.responseJSON?.errors) { + this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error"); + } + }); + } + + @action + onChangeChatChannelTitle(title) { + this.clearFlash(); + this.set("editedTitle", title); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js new file mode 100644 index 00000000000..57c3dc7072e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-index.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelIndexController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js new file mode 100644 index 00000000000..c7976a24ff7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js @@ -0,0 +1,20 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import showModal from "discourse/lib/show-modal"; + +export default class ChatChannelInfoAboutController extends Controller.extend( + ModalFunctionality +) { + @action + onEditChatChannelTitle() { + showModal("chat-channel-edit-title", { model: this.model?.chatChannel }); + } + + @action + onEditChatChannelDescription() { + showModal("chat-channel-edit-description", { + model: this.model?.chatChannel, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js new file mode 100644 index 00000000000..48e3c615581 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-members.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelInfoMembersController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js new file mode 100644 index 00000000000..a70d62d1ea9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-settings.js @@ -0,0 +1,3 @@ +import Controller from "@ember/controller"; + +export default class ChatChannelInfoSettingsController extends Controller {} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js new file mode 100644 index 00000000000..720e6f635f3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info.js @@ -0,0 +1,37 @@ +import Controller from "@ember/controller"; +import { action, computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { reads } from "@ember/object/computed"; + +export default class ChatChannelInfoIndexController extends Controller { + @service router; + @service chat; + @service chatChannelInfoRouteOriginManager; + + @reads("router.currentRoute.localName") tab; + + @computed("model.chatChannel.{membershipsCount,status}") + get tabs() { + const tabs = []; + + if (!this.model.chatChannel.isDirectMessageChannel) { + tabs.push("about"); + } + + if ( + this.model.chatChannel.isOpen && + this.model.chatChannel.membershipsCount >= 1 + ) { + tabs.push("members"); + } + + tabs.push("settings"); + + return tabs; + } + + @action + switchChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js new file mode 100644 index 00000000000..ac07dd24838 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-selector-modal.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { action } from "@ember/object"; + +export default class ChatChannelSelectorModalController extends Controller.extend( + ModalFunctionality +) { + @action + closeSelf() { + this.send("closeModal"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js new file mode 100644 index 00000000000..e3dfaf5a613 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-toggle.js @@ -0,0 +1,18 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelToggleController extends Controller.extend( + ModalFunctionality +) { + @service chat; + + chatChannel = null; + + @action + channelStatusChanged(channel) { + this.send("closeModal"); + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js new file mode 100644 index 00000000000..75d4a3b4ee9 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel.js @@ -0,0 +1,14 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelController extends Controller { + @service chat; + + queryParams = ["messageId"]; + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js new file mode 100644 index 00000000000..162d6a72ef7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-draft-channel.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatDraftChannelController extends Controller { + @service chat; + + @action + onSwitchChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js new file mode 100644 index 00000000000..fd2b50ff962 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-index.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatIndexController extends Controller { + @service chat; + + @action + selectChannel(channel) { + return this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js new file mode 100644 index 00000000000..86ebd1e8ab4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-message-move-to-channel-modal.js @@ -0,0 +1,8 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default class ChatMessageMoveToChannelModalController extends Controller.extend( + ModalFunctionality +) { + chatChannel = null; +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat.js b/plugins/chat/assets/javascripts/discourse/controllers/chat.js new file mode 100644 index 00000000000..d823b34f6e1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat.js @@ -0,0 +1,12 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; + +export default class ChatController extends Controller { + @service chat; + + @action + switchChannel(channel) { + this.chat.openChannel(channel); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js new file mode 100644 index 00000000000..00b9b8e0aef --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -0,0 +1,172 @@ +import { escapeExpression } from "discourse/lib/utilities"; +import Controller from "@ember/controller"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import I18n from "I18n"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { ajax } from "discourse/lib/ajax"; +import { action, computed } from "@ember/object"; +import { gt, notEmpty } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { isBlank } from "@ember/utils"; +import { htmlSafe } from "@ember/template"; + +const DEFAULT_HINT = htmlSafe( + I18n.t("chat.create_channel.choose_category.default_hint", { + link: "/categories", + category: "category", + }) +); + +export default class CreateChannelController extends Controller.extend( + ModalFunctionality +) { + @service chat; + @service dialog; + + category = null; + categoryId = null; + name = ""; + description = ""; + categoryPermissionsHint = null; + autoJoinUsers = null; + autoJoinWarning = ""; + + @notEmpty("category") categorySelected; + @gt("siteSettings.max_chat_auto_joined_users", 0) autoJoinAvailable; + + @computed("categorySelected", "name") + get createDisabled() { + return !this.categorySelected || isBlank(this.name); + } + + onShow() { + this.set("categoryPermissionsHint", DEFAULT_HINT); + } + + onClose() { + this.setProperties({ + categoryId: null, + category: null, + name: "", + description: "", + categoryPermissionsHint: DEFAULT_HINT, + autoJoinWarning: "", + }); + } + + _createChannel() { + const data = { + id: this.categoryId, + name: this.name, + description: this.description, + auto_join_users: this.autoJoinUsers, + }; + + return ajax("/chat/chat_channels", { method: "PUT", data }) + .then((response) => { + const chatChannel = ChatChannel.create(response.chat_channel); + + return this.chat.startTrackingChannel(chatChannel).then(() => { + this.send("closeModal"); + this.chat.openChannel(chatChannel); + }); + }) + .catch((e) => { + this.flash(e.jqXHR.responseJSON.errors[0], "error"); + }); + } + + _buildCategorySlug(category) { + const parent = category.parentCategory; + + if (parent) { + return `${this._buildCategorySlug(parent)}/${category.slug}`; + } else { + return category.slug; + } + } + + _updateAutoJoinConfirmWarning(category, catPermissions) { + const allowedGroups = catPermissions.allowed_groups; + + if (catPermissions.private) { + const warningTranslationKey = + allowedGroups.length < 3 ? "warning_groups" : "warning_multiple_groups"; + + this.set( + "autoJoinWarning", + I18n.t(`chat.create_channel.auto_join_users.${warningTranslationKey}`, { + members_count: catPermissions.members_count, + group: escapeExpression(allowedGroups[0]), + group_2: escapeExpression(allowedGroups[1]), + count: allowedGroups.length, + }) + ); + } else { + this.set( + "autoJoinWarning", + I18n.t(`chat.create_channel.auto_join_users.public_category_warning`, { + category: escapeExpression(category.name), + }) + ); + } + } + + _updatePermissionsHint(category) { + if (category) { + const fullSlug = this._buildCategorySlug(category); + + return ChatApi.categoryPermissions(category.id).then((catPermissions) => { + this._updateAutoJoinConfirmWarning(category, catPermissions); + const allowedGroups = catPermissions.allowed_groups; + const translationKey = + allowedGroups.length < 3 ? "hint_groups" : "hint_multiple_groups"; + + this.set( + "categoryPermissionsHint", + htmlSafe( + I18n.t(`chat.create_channel.choose_category.${translationKey}`, { + link: `/c/${escapeExpression(fullSlug)}/edit/security`, + hint: escapeExpression(allowedGroups[0]), + hint_2: escapeExpression(allowedGroups[1]), + count: allowedGroups.length, + }) + ) + ); + }); + } else { + this.set("categoryPermissionsHint", DEFAULT_HINT); + this.set("autoJoinWarning", ""); + } + } + + @action + onCategoryChange(categoryId) { + let category = categoryId + ? this.site.categories.findBy("id", categoryId) + : null; + this._updatePermissionsHint(category); + this.setProperties({ + categoryId, + category, + name: category?.name || "", + }); + } + + @action + create() { + if (this.createDisabled) { + return; + } + + if (this.autoJoinUsers) { + this.dialog.yesNoConfirm({ + message: this.autoJoinWarning, + didConfirm: () => this._createChannel(), + }); + } else { + this._createChannel(); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js new file mode 100644 index 00000000000..392dc8f7f91 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js @@ -0,0 +1,54 @@ +import Controller from "@ember/controller"; +import { isTesting } from "discourse-common/config/environment"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import { action } from "@ember/object"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { CHAT_SOUNDS } from "discourse/plugins/chat/discourse/initializers/chat-notification-sounds"; + +const CHAT_ATTRS = [ + "chat_enabled", + "only_chat_push_notifications", + "ignore_channel_wide_mention", + "chat_sound", + "chat_email_frequency", +]; + +const EMAIL_FREQUENCY_OPTIONS = [ + { name: I18n.t(`chat.email_frequency.never`), value: "never" }, + { name: I18n.t(`chat.email_frequency.when_away`), value: "when_away" }, +]; + +export default class PreferencesChatController extends Controller { + emailFrequencyOptions = EMAIL_FREQUENCY_OPTIONS; + + @discourseComputed + chatSounds() { + return Object.keys(CHAT_SOUNDS).map((value) => { + return { name: I18n.t(`chat.sounds.${value}`), value }; + }); + } + + @action + onChangeChatSound(sound) { + if (sound && !isTesting()) { + const audio = new Audio(CHAT_SOUNDS[sound]); + audio.play(); + } + this.model.set("user_option.chat_sound", sound); + } + + @action + save() { + this.set("saved", false); + return this.model + .save(CHAT_ATTRS) + .then(() => { + this.set("saved", true); + if (!isTesting()) { + location.reload(); + } + }) + .catch(popupAjaxError); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js b/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js new file mode 100644 index 00000000000..c08e6975950 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/chat-guardian.js @@ -0,0 +1,17 @@ +import Helper from "@ember/component/helper"; +import { inject as service } from "@ember/service"; +import { camelize } from "@ember/string"; + +export default class ChatGuardianHelper extends Helper { + @service chatGuardian; + + compute(inputs) { + const [key, ...params] = inputs; + + if (!key) { + return; + } + + return this.chatGuardian[camelize(key)]?.(...params); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js new file mode 100644 index 00000000000..9c0a1be4d76 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js @@ -0,0 +1,34 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import { htmlSafe } from "@ember/template"; +import getURL from "discourse-common/lib/get-url"; +import I18n from "I18n"; +import User from "discourse/models/user"; + +registerUnbound("format-chat-date", function (message, details, mode) { + let currentUser = User.current(); + + let tz = currentUser + ? currentUser.resolvedTimezone(currentUser) + : moment.tz.guess(); + + let date = moment(new Date(message.created_at), tz); + + let url = ""; + + if (details) { + url = getURL( + `/chat/channel/${details.chat_channel_id}/-?messageId=${message.id}` + ); + } + + let title = date.format(I18n.t("dates.long_with_year")); + + let display = + mode === "tiny" + ? date.format(I18n.t("chat.dates.time_tiny")) + : date.format(I18n.t("dates.time")); + + return htmlSafe( + `${display}` + ); +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/noop.js b/plugins/chat/assets/javascripts/discourse/helpers/noop.js new file mode 100644 index 00000000000..c224727fc2e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/noop.js @@ -0,0 +1,5 @@ +import { helper } from "@ember/component/helper"; + +export default helper(function noop() { + return () => {}; +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js new file mode 100644 index 00000000000..adcbee709f7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/slugify-channel.js @@ -0,0 +1,8 @@ +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import Helper from "@ember/component/helper"; + +export default class SlugifyChannel extends Helper { + compute(inputs) { + return slugifyChannel(inputs[0]); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js new file mode 100644 index 00000000000..f1fb7642d0a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-title.js @@ -0,0 +1,9 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +registerUnbound("tonable-emoji-title", function (emoji, diversity) { + if (!emoji.tonable || diversity === 1) { + return `:${emoji.name}:`; + } + + return `:${emoji.name}:t${diversity}:`; +}); diff --git a/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js new file mode 100644 index 00000000000..75e0bdc08d3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/helpers/tonable-emoji-url.js @@ -0,0 +1,9 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +registerUnbound("tonable-emoji-url", function (emoji, scale) { + if (!emoji.tonable || scale === 1) { + return emoji.url; + } + + return emoji.url.split(".png")[0] + `/${scale}.png`; +}); diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js new file mode 100644 index 00000000000..3a7d30aa185 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-decorators.js @@ -0,0 +1,154 @@ +import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import highlightSyntax from "discourse/lib/highlight-syntax"; +import I18n from "I18n"; +import DiscourseURL from "discourse/lib/url"; +import { samePrefix } from "discourse-common/lib/get-url"; +import loadScript from "discourse/lib/load-script"; +import { spinnerHTML } from "discourse/helpers/loading-spinner"; + +export default { + name: "chat-decorators", + + initializeWithPluginApi(api, container) { + api.decorateChatMessage((element) => decorateGithubOneboxBody(element), { + id: "onebox-github-body", + }); + + api.decorateChatMessage( + (element) => { + element + .querySelectorAll(".onebox.githubblob li.selected") + .forEach((line) => { + const scrollingElement = this._getScrollParent(line, "onebox"); + + // most likely a very small file which doesn’t need scrolling + if (!scrollingElement) { + return; + } + + const scrollBarWidth = + scrollingElement.offsetHeight - scrollingElement.clientHeight; + + scrollingElement.scroll({ + top: + line.offsetTop + + scrollBarWidth - + scrollingElement.offsetHeight / 2 + + line.offsetHeight / 2, + }); + }); + }, + { + id: "onebox-github-scrolling", + } + ); + + const siteSettings = container.lookup("service:site-settings"); + api.decorateChatMessage( + (element) => + highlightSyntax( + element, + siteSettings, + container.lookup("service:session") + ), + { id: "highlightSyntax" } + ); + + api.decorateChatMessage(this.renderChatTranscriptDates, { + id: "transcriptDates", + }); + + api.decorateChatMessage(this.forceLinksToOpenNewTab, { + id: "linksNewTab", + }); + + api.decorateChatMessage( + (element) => + this.lightbox(element.querySelectorAll("img:not(.emoji, .avatar)")), + { + id: "lightbox", + } + ); + }, + + _getScrollParent(node, maxParentSelector) { + if (node === null || node.classList.contains(maxParentSelector)) { + return null; + } + + if (node.scrollHeight > node.clientHeight) { + return node; + } else { + return this._getScrollParent(node.parentNode, maxParentSelector); + } + }, + + renderChatTranscriptDates(element) { + element.querySelectorAll(".chat-transcript").forEach((transcriptEl) => { + const dateTimeRaw = transcriptEl.dataset["datetime"]; + const dateTimeLinkEl = transcriptEl.querySelector( + ".chat-transcript-datetime a" + ); + + // we only show date for first message + if (!dateTimeLinkEl) { + return; + } + + if (dateTimeLinkEl.innerText !== "") { + // same as highlight, no need to do this for every single message every time + // any message changes + return; + } + + if (this.currentUserTimezone) { + dateTimeLinkEl.innerText = moment + .tz(dateTimeRaw, this.currentUserTimezone) + .format(I18n.t("dates.long_no_year")); + } else { + dateTimeLinkEl.innerText = moment(dateTimeRaw).format( + I18n.t("dates.long_no_year") + ); + } + }); + }, + + forceLinksToOpenNewTab(element) { + const links = element.querySelectorAll( + ".chat-message-text a:not([target='_blank'])" + ); + for (let linkIndex = 0; linkIndex < links.length; linkIndex++) { + const link = links[linkIndex]; + if (!DiscourseURL.isInternal(link.href) || !samePrefix(link.href)) { + link.setAttribute("target", "_blank"); + } + } + }, + + lightbox(images) { + loadScript("/javascripts/jquery.magnific-popup.min.js").then(function () { + $(images).magnificPopup({ + type: "image", + closeOnContentClick: false, + mainClass: "mfp-zoom-in", + tClose: I18n.t("lightbox.close"), + tLoading: spinnerHTML, + image: { + verticalFit: true, + }, + callbacks: { + elementParse: (item) => { + item.src = item.el[0].src; + }, + }, + }); + }); + }, + + initialize(container) { + withPluginApi("0.8.42", (api) => + this.initializeWithPluginApi(api, container) + ); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js new file mode 100644 index 00000000000..d80c9948892 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -0,0 +1,208 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import showModal from "discourse/lib/show-modal"; + +const APPLE = + navigator.platform.startsWith("Mac") || navigator.platform === "iPhone"; +export const KEY_MODIFIER = APPLE ? "meta" : "ctrl"; + +export default { + name: "chat-keyboard-shortcuts", + + initialize(container) { + const chatService = container.lookup("service:chat"); + if (!chatService.userCanChat) { + return; + } + + const appEvents = container.lookup("service:app-events"); + const openChannelSelector = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (document.getElementById("chat-channel-selector-modal-inner")) { + appEvents.trigger("chat-channel-selector-modal:close"); + } else { + showModal("chat-channel-selector-modal"); + } + }; + + const handleMoveUpShortcut = (e) => { + e.preventDefault(); + e.stopPropagation(); + chatService.switchChannelUpOrDown("up"); + }; + + const handleMoveDownShortcut = (e) => { + e.preventDefault(); + e.stopPropagation(); + chatService.switchChannelUpOrDown("down"); + }; + + const isChatComposer = (el) => el.classList.contains("chat-composer-input"); + const isInputSelection = (el) => { + const inputs = ["input", "textarea", "select", "button"]; + const elementTagName = el?.tagName.toLowerCase(); + + if (inputs.includes(elementTagName)) { + return false; + } + return true; + }; + const isDrawerExpanded = () => { + return document.querySelector(".topic-chat-float-container:not(.hidden)") + ? true + : false; + }; + + const modifyComposerSelection = (event, type) => { + if (!isChatComposer(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:modify-selection", { type }); + }; + + const openInsertLinkModal = (event) => { + if (!isChatComposer(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:open-insert-link-modal", { event }); + }; + + const openChatDrawer = (event) => { + if (!isInputSelection(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:toggle-open", event); + }; + + const closeChatDrawer = (event) => { + if (!isDrawerExpanded()) { + return; + } + + if (!isChatComposer(event.target)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + appEvents.trigger("chat:toggle-close", event); + }; + + withPluginApi("0.12.1", (api) => { + api.addKeyboardShortcut(`${KEY_MODIFIER}+k`, openChannelSelector, { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.open_quick_channel_selector", + definition: { + keys1: ["meta", "k"], + keysDelimiter: "plus", + }, + }, + }); + api.addKeyboardShortcut("alt+up", handleMoveUpShortcut, { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.switch_channel_arrows", + definition: { + keys1: ["alt", "↑"], + keys2: ["alt", "↓"], + keysDelimiter: "plus", + shortcutsDelimiter: "slash", + }, + }, + }); + + api.addKeyboardShortcut("alt+down", handleMoveDownShortcut, { + global: true, + }); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+b`, + (event) => modifyComposerSelection(event, "bold"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_bold", + definition: { + keys1: ["meta", "b"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+i`, + (event) => modifyComposerSelection(event, "italic"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_italic", + definition: { + keys1: ["meta", "i"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+e`, + (event) => modifyComposerSelection(event, "code"), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.composer_code", + definition: { + keys1: ["meta", "e"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut( + `${KEY_MODIFIER}+l`, + (event) => openInsertLinkModal(event), + { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.open_insert_link_modal", + definition: { + keys1: ["meta", "l"], + keysDelimiter: "plus", + }, + }, + } + ); + api.addKeyboardShortcut(`-`, (event) => openChatDrawer(event), { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.drawer_open", + definition: { + keys1: ["-"], + }, + }, + }); + api.addKeyboardShortcut("esc", (event) => closeChatDrawer(event), { + global: true, + help: { + category: "chat", + name: "chat.keyboard_shortcuts.drawer_close", + definition: { + keys1: ["esc"], + }, + }, + }); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-notification-sounds.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-notification-sounds.js new file mode 100644 index 00000000000..3a9548c739c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-notification-sounds.js @@ -0,0 +1,47 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import discourseDebounce from "discourse-common/lib/debounce"; + +export const CHAT_SOUNDS = { + bell: "/plugins/chat/audio/bell.mp3", + ding: "/plugins/chat/audio/ding.mp3", +}; + +const MENTION = 29; +const MESSAGE = 30; +const CHAT_NOTIFICATION_TYPES = [MENTION, MESSAGE]; + +const AUDIO_DEBOUNCE_TIMEOUT = 3000; + +export default { + name: "chat-notification-sounds", + initialize(container) { + const currentUser = container.lookup("service:current-user"); + const chatService = container.lookup("service:chat"); + + if (!chatService.userCanChat || !currentUser?.chat_sound) { + return; + } + + function playAudio(user) { + const audio = new Audio(CHAT_SOUNDS[user.chat_sound]); + audio.play().catch(() => { + // eslint-disable-next-line no-console + console.info( + "User needs to interact with DOM before we can play notification sounds" + ); + }); + } + + function playAudioWithDebounce(user) { + discourseDebounce(this, playAudio, user, AUDIO_DEBOUNCE_TIMEOUT, true); + } + + withPluginApi("0.12.1", (api) => { + api.registerDesktopNotificationHandler((data, siteSettings, user) => { + if (CHAT_NOTIFICATION_TYPES.includes(data.notification_type)) { + playAudioWithDebounce(user); + } + }); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js new file mode 100644 index 00000000000..3b4e4ea824d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js @@ -0,0 +1,58 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { applyLocalDates } from "discourse/lib/local-dates"; + +export default { + name: "chat-plugin-decorators", + + initializeWithPluginApi(api, siteSettings) { + api.decorateChatMessage( + (element) => { + applyLocalDates( + element.querySelectorAll(".discourse-local-date"), + siteSettings + ); + }, + { + id: "local-dates", + } + ); + + if (siteSettings.spoiler_enabled) { + const applySpoiler = requirejs( + "discourse/plugins/discourse-spoiler-alert/lib/apply-spoiler" + ).default; + api.decorateChatMessage( + (element) => { + element.querySelectorAll(".spoiler").forEach((spoiler) => { + spoiler.classList.remove("spoiler"); + spoiler.classList.add("spoiled"); + applySpoiler(spoiler); + }); + }, + { + id: "spoiler", + } + ); + } + + api.decorateChatMessage( + (element) => { + element + .querySelectorAll(".lazyYT:not(.lazyYT-video-loaded)") + .forEach((iframe) => { + $(iframe).lazyYT(); + }); + }, + { + id: "lazy-yt", + } + ); + }, + + initialize(container) { + const siteSettings = container.lookup("service:site-settings"); + withPluginApi("0.8.42", (api) => + this.initializeWithPluginApi(api, siteSettings) + ); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js new file mode 100644 index 00000000000..af4fc496da3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -0,0 +1,182 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import I18n from "I18n"; +import { bind } from "discourse-common/utils/decorators"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { MENTION_KEYWORDS } from "discourse/plugins/chat/discourse/components/chat-message"; +import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +let _lastForcedRefreshAt; +const MIN_REFRESH_DURATION_MS = 180000; // 3 minutes + +export default { + name: "chat-setup", + initialize(container) { + this.chatService = container.lookup("service:chat"); + this.siteSettings = container.lookup("service:site-settings"); + + this.appEvents = container.lookup("service:appEvents"); + this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged"); + + withPluginApi("0.12.1", (api) => { + api.registerChatComposerButton({ + id: "chat-upload-btn", + icon: "far-image", + label: "chat.upload", + position: "dropdown", + action: "uploadClicked", + dependentKeys: ["canAttachUploads"], + displayed() { + return this.canAttachUploads; + }, + }); + + if (this.siteSettings.discourse_local_dates_enabled) { + api.registerChatComposerButton({ + label: "discourse_local_dates.title", + id: "local-dates", + class: "chat-local-dates-btn", + icon: "calendar-alt", + position: "dropdown", + action() { + this.insertDiscourseLocalDate(); + }, + }); + } + + if (this.siteSettings.enable_experimental_hashtag_autocomplete) { + api.registerHashtagSearchParam("category", "chat-composer", 100); + api.registerHashtagSearchParam("tag", "chat-composer", 50); + } + + api.registerChatComposerButton({ + label: "chat.emoji", + id: "emoji", + class: "chat-emoji-btn", + icon: "discourse-emojis", + position: "dropdown", + action() { + const chatEmojiPickerManager = container.lookup( + "service:chat-emoji-picker-manager" + ); + chatEmojiPickerManager.startFromComposer(this.didSelectEmoji); + }, + }); + + // we want to decorate the chat quote dates regardless + // of whether the current user has chat enabled + api.decorateCookedElement( + (elem) => { + const currentUser = getOwner(this).lookup("service:current-user"); + const currentUserTimezone = + currentUser?.resolvedTimezone(currentUser); + const chatTranscriptElements = + elem.querySelectorAll(".chat-transcript"); + + chatTranscriptElements.forEach((el) => { + const dateTimeRaw = el.dataset["datetime"]; + const dateTimeEl = el.querySelector( + ".chat-transcript-datetime a, .chat-transcript-datetime span" + ); + + if (currentUserTimezone) { + dateTimeEl.innerText = moment + .tz(dateTimeRaw, currentUserTimezone) + .format(I18n.t("dates.long_no_year")); + } else { + dateTimeEl.innerText = moment(dateTimeRaw).format( + I18n.t("dates.long_no_year") + ); + } + }); + }, + { id: "chat-transcript-datetime" } + ); + + if (!this.chatService.userCanChat) { + return; + } + + document.body.classList.add("chat-enabled"); + + const currentUser = api.getCurrentUser(); + if (currentUser?.chat_channels) { + this.chatService.setupWithPreloadedChannels(currentUser.chat_channels); + } else { + this.chatService.getChannels(); + } + + const chatNotificationManager = container.lookup( + "service:chat-notification-manager" + ); + chatNotificationManager.start(); + + if (!this._registeredDocumentTitleCountCallback) { + api.addDocumentTitleCounter(this.documentTitleCountCallback); + this._registeredDocumentTitleCountCallback = true; + } + + api.addCardClickListenerSelector(".topic-chat-float-container"); + + api.dispatchWidgetAppEvent( + "site-header", + "header-chat-link", + "chat:rerender-header" + ); + + api.dispatchWidgetAppEvent( + "sidebar-header", + "header-chat-link", + "chat:rerender-header" + ); + + api.addToHeaderIcons("header-chat-link"); + + api.decorateChatMessage(function (chatMessage) { + if (!this.currentUser) { + return; + } + + const highlightable = [ + `@${this.currentUser.username}`, + ...MENTION_KEYWORDS.map((k) => `@${k}`), + ]; + + chatMessage.querySelectorAll(".mention").forEach((node) => { + const mention = node.textContent.trim(); + if (highlightable.includes(mention)) { + node.classList.add("highlighted", "valid-mention"); + } + }); + }); + }); + }, + + @bind + documentTitleCountCallback() { + return this.chatService.getDocumentTitleCount(); + }, + + teardown() { + this.appEvents.off("discourse:focus-changed", this, "_handleFocusChanged"); + _lastForcedRefreshAt = null; + clearChatComposerButtons(); + }, + + @bind + _handleFocusChanged(hasFocus) { + if (!hasFocus) { + _lastForcedRefreshAt = Date.now(); + return; + } + + _lastForcedRefreshAt = _lastForcedRefreshAt || Date.now(); + + const duration = Date.now() - _lastForcedRefreshAt; + if (duration <= MIN_REFRESH_DURATION_MS) { + return; + } + + _lastForcedRefreshAt = Date.now(); + this.chatService.refreshTrackingState(); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js new file mode 100644 index 00000000000..0bdae015b27 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-sidebar.js @@ -0,0 +1,479 @@ +import { htmlSafe } from "@ember/template"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import I18n from "I18n"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; +import { DRAFT_CHANNEL_VIEW } from "discourse/plugins/chat/discourse/services/chat"; +import { avatarUrl, escapeExpression } from "discourse/lib/utilities"; +import { dasherize } from "@ember/string"; +import { emojiUnescape } from "discourse/lib/text"; +import { decorateUsername } from "discourse/helpers/decorate-username-selector"; +import { until } from "discourse/lib/formatter"; +import { inject as service } from "@ember/service"; + +export default { + name: "chat-sidebar", + initialize(container) { + this.chatService = container.lookup("service:chat"); + + if (!this.chatService.userCanChat) { + return; + } + + withPluginApi("1.3.0", (api) => { + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const SidebarChatChannelsSectionLink = class extends BaseCustomSidebarSectionLink { + @tracked chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + + constructor({ channel, chatService }) { + super(...arguments); + this.channel = channel; + this.chatService = chatService; + + this.chatService.appEvents.on( + "chat:user-tracking-state-changed", + this._refreshTrackingState + ); + } + + @bind + willDestroy() { + this.chatService.appEvents.off( + "chat:user-tracking-state-changed", + this._refreshTrackingState + ); + } + + @bind + _refreshTrackingState() { + this.chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + } + + get name() { + return dasherize(slugifyChannel(this.title)); + } + + get route() { + return "chat.channel"; + } + + get models() { + return [this.channel.id, slugifyChannel(this.title)]; + } + + get text() { + return htmlSafe(emojiUnescape(this.title)); + } + + get prefixType() { + return "icon"; + } + + get prefixValue() { + return "hashtag"; + } + + get prefixColor() { + return this.channel.chatable.color; + } + + get title() { + return this.channel.escapedTitle; + } + + get prefixBadge() { + return this.channel.chatable.read_restricted ? "lock" : ""; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + return this.chatChannelTrackingState?.unread_count > 0 + ? "circle" + : ""; + } + + get suffixCSSClass() { + return this.chatChannelTrackingState?.unread_mentions > 0 + ? "urgent" + : "unread"; + } + + get contentCSSClass() { + return this.channel.current_user_membership.muted + ? "sidebar-section-link-content-muted" + : ""; + } + }; + + const SidebarChatChannelsSection = class extends BaseCustomSidebarSection { + @tracked sectionLinks = []; + + @tracked sectionIndicator = + this.chatService.publicChannels && + this.chatService.publicChannels[0].current_user_membership + .unread_count; + + @tracked currentUserCanJoinPublicChannels = + this.sidebar.currentUser && + (this.sidebar.currentUser.staff || + this.sidebar.currentUser.has_joinable_public_channels); + + constructor() { + super(...arguments); + + if (container.isDestroyed) { + return; + } + this.chatService = container.lookup("service:chat"); + this.chatService.appEvents.on( + "chat:refresh-channels", + this._refreshChannels + ); + this._refreshChannels(); + } + + @bind + willDestroy() { + if (!this.chatService) { + return; + } + this.chatService.appEvents.off( + "chat:refresh-channels", + this._refreshChannels + ); + } + + @bind + _refreshChannels() { + const newSectionLinks = []; + this.chatService.getChannels().then((channels) => { + channels.publicChannels.forEach((channel) => { + newSectionLinks.push( + new SidebarChatChannelsSectionLink({ + channel, + chatService: this.chatService, + }) + ); + }); + this.sectionLinks = newSectionLinks; + }); + } + + get name() { + return "chat-channels"; + } + + get title() { + return I18n.t("chat.chat_channels"); + } + + get text() { + return I18n.t("chat.chat_channels"); + } + + get actions() { + return [ + { + id: "browseChannels", + title: I18n.t("chat.channels_list_popup.browse"), + action: () => { + this.chatService.router.transitionTo("chat.browse"); + }, + }, + ]; + } + + get actionsIcon() { + return "pencil-alt"; + } + + get links() { + return this.sectionLinks; + } + + get displaySection() { + return ( + this.sectionLinks.length > 0 || + this.currentUserCanJoinPublicChannels + ); + } + }; + + return SidebarChatChannelsSection; + } + ); + + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const SidebarChatDirectMessagesSectionLink = class extends BaseCustomSidebarSectionLink { + @tracked chatChannelTrackingState = + this.chatService.currentUser.chat_channel_tracking_state[ + this.channel.id + ]; + + constructor({ channel, chatService }) { + super(...arguments); + this.channel = channel; + this.chatService = chatService; + + if (this.oneOnOneMessage) { + this.channel.chatable.users[0].trackStatus(); + } + } + + @bind + willDestroy() { + if (this.oneOnOneMessage) { + this.channel.chatable.users[0].stopTrackingStatus(); + } + } + + get name() { + return slugifyChannel(this.title); + } + + get route() { + return "chat.channel"; + } + + get models() { + return [this.channel.id, slugifyChannel(this.title)]; + } + + get title() { + return this.channel.escapedTitle; + } + + get oneOnOneMessage() { + return this.channel.chatable.users.length === 1; + } + + get text() { + const username = this.title.replaceAll("@", ""); + if (this.oneOnOneMessage) { + const status = this.channel.chatable.users[0].get("status"); + const statusHtml = status ? this._userStatusHtml(status) : ""; + return htmlSafe( + `${escapeExpression( + username + )}${statusHtml} ${decorateUsername( + escapeExpression(username) + )}` + ); + } else { + return username; + } + } + + get prefixType() { + if (this.oneOnOneMessage) { + return "image"; + } else { + return "text"; + } + } + + get prefixValue() { + if (this.channel.chatable.users.length === 1) { + return avatarUrl( + this.channel.chatable.users[0].avatar_template, + "tiny" + ); + } else { + return this.channel.chatable.users.length; + } + } + + get prefixCSSClass() { + const activeUsers = this.chatService.presenceChannel.users; + const user = this.channel.chatable.users[0]; + if ( + !!activeUsers?.findBy("id", user?.id) || + !!activeUsers?.findBy("username", user?.username) + ) { + return "active"; + } + return ""; + } + + get contentCSSClass() { + return this.channel.current_user_membership.muted + ? "sidebar-section-link-content-muted" + : ""; + } + + get suffixType() { + return "icon"; + } + + get suffixValue() { + return this.chatChannelTrackingState?.unread_count > 0 + ? "circle" + : ""; + } + + get suffixCSSClass() { + return "urgent"; + } + + get hoverType() { + return "icon"; + } + + get hoverValue() { + return "times"; + } + + get hoverAction() { + return () => { + this.chatService.unfollowChannel(this.channel); + }; + } + + get hoverTitle() { + return I18n.t("chat.direct_messages.leave"); + } + + _userStatusHtml(status) { + const emoji = escapeExpression(`:${status.emoji}:`); + const title = this._userStatusTitle(status); + return `${emojiUnescape(emoji, { + title, + })}`; + } + + _userStatusTitle(status) { + let title = `${escapeExpression(status.description)}`; + + if (status.ends_at) { + const untilFormatted = until( + status.ends_at, + this.chatService.currentUser.timezone, + this.chatService.currentUser.locale + ); + title += ` ${untilFormatted}`; + } + + return title; + } + }; + + const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection { + @service site; + @tracked sectionLinks = []; + @tracked userCanDirectMessage = + this.chatService.userCanDirectMessage; + + constructor() { + super(...arguments); + + if (container.isDestroyed) { + return; + } + this.chatService = container.lookup("service:chat"); + this.chatService.appEvents.on( + "chat:user-tracking-state-changed", + this._refreshDirectMessageChannels + ); + this._refreshDirectMessageChannels(); + } + + @bind + willDestroy() { + if (container.isDestroyed) { + return; + } + this.chatService.appEvents.off( + "chat:user-tracking-state-changed", + this._refreshDirectMessageChannels + ); + } + + @bind + _refreshDirectMessageChannels() { + const newSectionLinks = []; + this.chatService.getChannels().then((channels) => { + this.chatService + .truncateDirectMessageChannels(channels.directMessageChannels) + .forEach((channel) => { + newSectionLinks.push( + new SidebarChatDirectMessagesSectionLink({ + channel, + chatService: this.chatService, + }) + ); + }); + this.sectionLinks = newSectionLinks; + }); + } + + get name() { + return "chat-dms"; + } + + get title() { + return I18n.t("chat.direct_messages.title"); + } + + get text() { + return I18n.t("chat.direct_messages.title"); + } + + get actions() { + if (!this.userCanDirectMessage) { + return []; + } + + return [ + { + id: "startDm", + title: I18n.t("chat.direct_messages.new"), + action: () => { + if ( + this.site.mobileView || + this.chatService.router.currentRouteName.startsWith("") + ) { + this.chatService.router.transitionTo( + "chat.draft-channel" + ); + } else { + this.appEvents.trigger( + "chat:open-view", + DRAFT_CHANNEL_VIEW + ); + } + }, + }, + ]; + } + + get actionsIcon() { + return "plus"; + } + + get links() { + return this.sectionLinks; + } + + get displaySection() { + return this.sectionLinks.length > 0 || this.userCanDirectMessage; + } + }; + + return SidebarChatDirectMessagesSection; + } + ); + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js new file mode 100644 index 00000000000..192203a506a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-menu.js @@ -0,0 +1,134 @@ +import I18n from "I18n"; + +import { withPluginApi } from "discourse/lib/plugin-api"; +import { formatUsername } from "discourse/lib/utilities"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +export default { + name: "chat-user-menu", + initialize(container) { + withPluginApi("1.3.0", (api) => { + const chat = container.lookup("service:chat"); + + if (!chat.userCanChat) { + return; + } + + if (api.registerNotificationTypeRenderer) { + api.registerNotificationTypeRenderer( + "chat_invitation", + (NotificationItemBase) => { + return class extends NotificationItemBase { + get linkHref() { + const title = this.notification.data.chat_channel_title + ? slugifyChannel(this.notification.data.chat_channel_title) + : "-"; + + return `/chat/channel/${this.notification.data.chat_channel_id}/${title}?messageId=${this.notification.data.chat_message_id}`; + } + + get linkTitle() { + return I18n.t("notifications.titles.chat_invitation"); + } + + get icon() { + return "link"; + } + + get label() { + return formatUsername( + this.notification.data.invited_by_username + ); + } + + get description() { + return I18n.t("notifications.chat_invitation"); + } + }; + } + ); + + api.registerNotificationTypeRenderer( + "chat_mention", + (NotificationItemBase) => { + return class extends NotificationItemBase { + get linkHref() { + const title = this.notification.data.chat_channel_title + ? slugifyChannel(this.notification.data.chat_channel_title) + : "-"; + + return `/chat/channel/${this.notification.data.chat_channel_id}/${title}?messageId=${this.notification.data.chat_message_id}`; + } + + get linkTitle() { + return I18n.t("notifications.titles.chat_mention"); + } + + get icon() { + return "comment"; + } + + get label() { + return formatUsername( + this.notification.data.mentioned_by_username + ); + } + + get description() { + const identifier = this.notification.data.identifier + ? `@${this.notification.data.identifier}` + : null; + + const i18nPrefix = this.notification.data + .is_direct_message_channel + ? "notifications.popup.direct_message_chat_mention" + : "notifications.popup.chat_mention"; + + const i18nSuffix = identifier ? "other_plain" : "direct"; + + return I18n.t(`${i18nPrefix}.${i18nSuffix}`, { + identifier, + channel: this.notification.data.chat_channel_title, + }); + } + }; + } + ); + } + + if (api.registerUserMenuTab) { + api.registerUserMenuTab((UserMenuTab) => { + return class extends UserMenuTab { + get id() { + return "chat-notifications"; + } + + get panelComponent() { + return "user-menu/chat-notifications-list"; + } + + get icon() { + return "comment"; + } + + get count() { + return ( + this.getUnreadCountForType("chat_mention") + + this.getUnreadCountForType("chat_invitation") + ); + } + + get notificationTypes() { + return [ + "chat_invitation", + "chat_mention", + "chat_message", + "chat_quoted", + ]; + } + }; + }); + } + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js new file mode 100644 index 00000000000..e7b9af19561 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-user-options.js @@ -0,0 +1,24 @@ +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 CHAT_SOUND = "chat_sound"; +const CHAT_EMAIL_FREQUENCY = "chat_email_frequency"; + +export default { + name: "chat-user-options", + + initialize(container) { + withPluginApi("0.11.0", (api) => { + const siteSettings = container.lookup("service:site-settings"); + if (siteSettings.chat_enabled) { + api.addSaveableUserOptionField(CHAT_ENABLED_FIELD); + api.addSaveableUserOptionField(ONLY_CHAT_PUSH_NOTIFICATIONS_FIELD); + api.addSaveableUserOptionField(IGNORE_CHANNEL_WIDE_MENTION); + api.addSaveableUserOptionField(CHAT_SOUND); + api.addSaveableUserOptionField(CHAT_EMAIL_FREQUENCY); + } + }); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-api.js b/plugins/chat/assets/javascripts/discourse/lib/chat-api.js new file mode 100644 index 00000000000..3b373570a72 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-api.js @@ -0,0 +1,95 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +export default class ChatApi { + static async chatChannelMemberships(channelId, data) { + return await ajax(`/chat/api/chat_channels/${channelId}/memberships.json`, { + data, + }).catch(popupAjaxError); + } + + static async updateChatChannelNotificationsSettings(channelId, data = {}) { + return await ajax( + `/chat/api/chat_channels/${channelId}/notifications_settings.json`, + { + method: "PUT", + data, + } + ).catch(popupAjaxError); + } + + static async sendMessage(channelId, data = {}) { + return ajax(`/chat/${channelId}.json`, { + ignoreUnsent: false, + method: "POST", + data, + }); + } + + static async chatChannels(data = {}) { + if (data?.status === "all") { + delete data.status; + } + + return await ajax(`/chat/api/chat_channels.json`, { + method: "GET", + data, + }) + .then((channels) => + channels.map((channel) => ChatChannel.create(channel)) + ) + .catch(popupAjaxError); + } + + static async modifyChatChannel(channelId, data) { + return await this._performRequest( + `/chat/api/chat_channels/${channelId}.json`, + { + method: "PUT", + data, + } + ); + } + + static async unfollowChatChannel(channel) { + return await this._performRequest( + `/chat/chat_channels/${channel.id}/unfollow.json`, + { + method: "POST", + } + ).then((updatedChannel) => { + channel.updateMembership(updatedChannel.current_user_membership); + + // doesn't matter if this is inaccurate, it will be eventually consistent + // via the channel-metadata MessageBus channel + channel.set("memberships_count", channel.memberships_count - 1); + return channel; + }); + } + + static async followChatChannel(channel) { + return await this._performRequest( + `/chat/chat_channels/${channel.id}/follow.json`, + { + method: "POST", + } + ).then((updatedChannel) => { + channel.updateMembership(updatedChannel.current_user_membership); + + // doesn't matter if this is inaccurate, it will be eventually consistent + // via the channel-metadata MessageBus channel + channel.set("memberships_count", channel.memberships_count + 1); + return channel; + }); + } + + static async categoryPermissions(categoryId) { + return await this._performRequest( + `/chat/api/category-chatables/${categoryId}/permissions.json` + ); + } + + static async _performRequest(...args) { + return await ajax(...args).catch(popupAjaxError); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js new file mode 100644 index 00000000000..94ca01e9729 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js @@ -0,0 +1,126 @@ +import I18n from "I18n"; + +let _chatComposerButtons = {}; + +export function registerChatComposerButton(button) { + if (!button.id) { + throw new Error("Attempted to register a chat composer button with no id."); + } + + if (_chatComposerButtons[button.id]) { + return; + } + + const defaultButton = { + id: null, + action: null, + icon: null, + title: null, + translatedTitle: null, + label: null, + translatedLabel: null, + ariaLabel: null, + translatedAriaLabel: null, + position: "inline", + classNames: [], + dependentKeys: [], + displayed: true, + disabled: false, + priority: 0, + }; + + const normalizedButton = Object.assign(defaultButton, button); + + if ( + !normalizedButton.icon && + !normalizedButton.label && + !normalizedButton.translatedLabel + ) { + throw new Error( + `Attempted to register a chat composer button: ${button.id} with no icon or label.` + ); + } + + _chatComposerButtons[normalizedButton.id] = normalizedButton; +} + +function computeButton(context, button, property) { + const field = button[property]; + + if (isFunction(field)) { + return field.apply(context); + } + + return field; +} + +function isFunction(descriptor) { + return descriptor && typeof descriptor === "function"; +} + +export function chatComposerButtonsDependentKeys() { + return [].concat( + ...Object.values(_chatComposerButtons) + .mapBy("dependentKeys") + .filter(Boolean) + ); +} + +export function chatComposerButtons(context, position) { + return Object.values(_chatComposerButtons) + .filter( + (button) => + computeButton(context, button, "displayed") && + computeButton(context, button, "position") === position + ) + .map((button) => { + const result = { id: button.id }; + + const label = computeButton(context, button, "label"); + result.label = label + ? label + : computeButton(context, button, "translatedLabel"); + + const ariaLabel = computeButton(context, button, "ariaLabel"); + if (ariaLabel) { + result.ariaLabel = I18n.t(ariaLabel); + } else { + const translatedAriaLabel = computeButton( + context, + button, + "translatedAriaLabel" + ); + result.ariaLabel = translatedAriaLabel || result.label; + } + + const title = computeButton(context, button, "title"); + result.title = title + ? I18n.t(title) + : computeButton(context, button, "translatedTitle"); + + result.classNames = ( + computeButton(context, button, "classNames") || [] + ).join(" "); + + result.icon = computeButton(context, button, "icon"); + result.disabled = computeButton(context, button, "disabled"); + result.priority = computeButton(context, button, "priority"); + + if (isFunction(button.action)) { + result.action = () => { + button.action.apply(context); + }; + } else { + const actionName = button.action; + result.action = () => { + context[actionName](); + }; + } + + return result; + }); +} + +export function clearChatComposerButtons() { + _chatComposerButtons = []; +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js new file mode 100644 index 00000000000..60a20c2206a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-flag.js @@ -0,0 +1,91 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; +import getURL from "discourse-common/lib/get-url"; + +export default class ChatMessageFlag { + title() { + return "flagging.title"; + } + + customSubmitLabel() { + return "flagging.notify_action"; + } + + submitLabel() { + return "chat.flagging.action"; + } + + targetsTopic() { + return false; + } + + editable() { + return false; + } + + _rewriteFlagDescriptions(flags) { + return flags.map((flag) => { + flag.set( + "description", + I18n.t(`chat.flags.${flag.name_key}`, { basePath: getURL("") }) + ); + return flag; + }); + } + + flagsAvailable(_controller, site, model) { + let flagsAvailable = site.flagTypes; + + flagsAvailable = flagsAvailable.filter((flag) => { + return model.available_flags.includes(flag.name_key); + }); + + // "message user" option should be at the top + const notifyUserIndex = flagsAvailable.indexOf( + flagsAvailable.filterBy("name_key", "notify_user")[0] + ); + + if (notifyUserIndex !== -1) { + const notifyUser = flagsAvailable[notifyUserIndex]; + flagsAvailable.splice(notifyUserIndex, 1); + flagsAvailable.splice(0, 0, notifyUser); + } + + return this._rewriteFlagDescriptions(flagsAvailable); + } + + create(controller, opts) { + controller.send("hideModal"); + + return ajax("/chat/flag", { + method: "PUT", + data: { + chat_message_id: controller.get("model.id"), + flag_type_id: controller.get("selected.id"), + message: opts.message, + is_warning: opts.isWarning, + take_action: opts.takeAction, + queue_for_review: opts.queue_for_review, + }, + }) + .then(() => { + if (controller.isDestroying || controller.isDestroyed) { + return; + } + + if (!opts.skipClose) { + controller.send("closeModal"); + } + if (opts.message) { + controller.set("message", ""); + } + }) + .catch((error) => { + if (!controller.isDestroying && !controller.isDestroyed) { + controller.send("closeModal"); + } + popupAjaxError(error); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js b/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js new file mode 100644 index 00000000000..37f85fb2c51 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/simple-category-hash-mention-transform.js @@ -0,0 +1,43 @@ +import getURL from "discourse-common/lib/get-url"; + +const domParser = new DOMParser(); + +export default function transform(cooked, categories) { + let html = domParser.parseFromString(cooked, "text/html"); + transformMentions(html); + transformCategoryTagHashes(html, categories); + return html.body.innerHTML; +} + +function transformMentions(html) { + (html.querySelectorAll("span.mention") || []).forEach((mentionSpan) => { + let mentionLink = document.createElement("a"); + let mentionText = document.createTextNode(mentionSpan.innerText); + mentionLink.classList.add("mention"); + mentionLink.appendChild(mentionText); + mentionLink.href = getURL(`/u/${mentionSpan.innerText.substring(1)}`); + mentionSpan.parentNode.replaceChild(mentionLink, mentionSpan); + }); +} + +function transformCategoryTagHashes(html, categories) { + (html.querySelectorAll("span.hashtag") || []).forEach((hashSpan) => { + const categoryTagName = hashSpan.innerText.substring(1); + const matchingCategory = categories.find( + (category) => + category.name.toLowerCase() === categoryTagName.toLowerCase() + ); + const href = getURL( + matchingCategory + ? `/c/${matchingCategory.name}/${matchingCategory.id}` + : `/tag/${categoryTagName}` + ); + + let hashLink = document.createElement("a"); + let hashText = document.createTextNode(hashSpan.innerText); + hashLink.classList.add("hashtag"); + hashLink.appendChild(hashText); + hashLink.href = href; + hashSpan.parentNode.replaceChild(hashLink, hashSpan); + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js new file mode 100644 index 00000000000..885f9d11dea --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/slugify-channel.js @@ -0,0 +1,8 @@ +import { slugify } from "discourse/lib/utilities"; + +export default function slugifyChannel(title) { + const slug = slugify(title); + return ( + slug.length ? slug : title.trim().toLowerCase().replace(/\s|_+/g, "-") + ).slice(0, 100); +} diff --git a/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js b/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js new file mode 100644 index 00000000000..094b9b52636 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js @@ -0,0 +1,10 @@ +import { isTesting } from "discourse-common/config/environment"; + +// return true when the browser viewport is zoomed +export default function isZoomed() { + return ( + !isTesting() && + visualViewport?.scale !== 1 && + document.documentElement.clientWidth / window.innerWidth !== 1 + ); +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js new file mode 100644 index 00000000000..0bc2b796523 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -0,0 +1,196 @@ +import RestModel from "discourse/models/rest"; +import I18n from "I18n"; +import { computed } from "@ember/object"; +import User from "discourse/models/user"; +import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership"; +import { ajax } from "discourse/lib/ajax"; +import { escapeExpression } from "discourse/lib/utilities"; + +export const CHATABLE_TYPES = { + directMessageChannel: "DirectMessageChannel", + categoryChannel: "Category", +}; +export const CHANNEL_STATUSES = { + open: "open", + readOnly: "read_only", + closed: "closed", + archived: "archived", +}; + +export function channelStatusName(channelStatus) { + switch (channelStatus) { + case CHANNEL_STATUSES.open: + return I18n.t("chat.channel_status.open"); + case CHANNEL_STATUSES.readOnly: + return I18n.t("chat.channel_status.read_only"); + case CHANNEL_STATUSES.closed: + return I18n.t("chat.channel_status.closed"); + case CHANNEL_STATUSES.archived: + return I18n.t("chat.channel_status.archived"); + } +} + +export function channelStatusIcon(channelStatus) { + if (channelStatus === CHANNEL_STATUSES.open) { + return null; + } + + switch (channelStatus) { + case CHANNEL_STATUSES.closed: + return "lock"; + break; + case CHANNEL_STATUSES.readOnly: + return "comment-slash"; + break; + case CHANNEL_STATUSES.archived: + return "archive"; + break; + } +} + +const STAFF_READONLY_STATUSES = [ + CHANNEL_STATUSES.readOnly, + CHANNEL_STATUSES.archived, +]; + +const READONLY_STATUSES = [ + CHANNEL_STATUSES.closed, + CHANNEL_STATUSES.readOnly, + CHANNEL_STATUSES.archived, +]; + +export default class ChatChannel extends RestModel { + isDraft = false; + lastSendReadMessageId = null; + + @computed("title") + get escapedTitle() { + return escapeExpression(this.title); + } + + @computed("description") + get escapedDescription() { + return escapeExpression(this.description); + } + + @computed("chatable_type") + get isDirectMessageChannel() { + return this.chatable_type === CHATABLE_TYPES.directMessageChannel; + } + + @computed("chatable_type") + get isCategoryChannel() { + return this.chatable_type === CHATABLE_TYPES.categoryChannel; + } + + @computed("status") + get isOpen() { + return !this.status || this.status === CHANNEL_STATUSES.open; + } + + @computed("status") + get isReadOnly() { + return this.status === CHANNEL_STATUSES.readOnly; + } + + @computed("status") + get isClosed() { + return this.status === CHANNEL_STATUSES.closed; + } + + @computed("status") + get isArchived() { + return this.status === CHANNEL_STATUSES.archived; + } + + @computed("isArchived", "isOpen") + get isJoinable() { + return this.isOpen && !this.isArchived; + } + + @computed("memberships_count") + get membershipsCount() { + return this.memberships_count; + } + + @computed("current_user_membership.following") + get isFollowing() { + return this.current_user_membership.following; + } + + canModifyMessages(user) { + if (user.staff) { + return !STAFF_READONLY_STATUSES.includes(this.status); + } + + return !READONLY_STATUSES.includes(this.status); + } + + updateMembership(membership) { + this.current_user_membership.setProperties({ + following: membership.following, + muted: membership.muted, + desktop_notification_level: membership.desktop_notification_level, + mobile_notification_level: membership.mobile_notification_level, + }); + } + + updateLastReadMessage(messageId) { + if (!this.isFollowing || !messageId) { + return; + } + + return ajax(`/chat/${this.id}/read/${messageId}.json`, { + method: "PUT", + }).then(() => { + this.set("lastSendReadMessageId", messageId); + }); + } +} + +ChatChannel.reopenClass({ + create(args) { + args = args || {}; + this._initUserModels(args); + this._initUserMembership(args); + + args.lastSendReadMessageId = + args.current_user_membership?.last_read_message_id; + + return this._super(args); + }, + + _initUserModels(args) { + if (args.chatable?.users?.length) { + for (let i = 0; i < args.chatable?.users?.length; i++) { + const userData = args.chatable.users[i]; + args.chatable.users[i] = User.create(userData); + } + } + }, + + _initUserMembership(args) { + if (args.current_user_membership instanceof UserChatChannelMembership) { + return; + } + + args.current_user_membership = UserChatChannelMembership.create( + args.current_user_membership || { + following: false, + muted: false, + unread_count: 0, + unread_mentions: 0, + } + ); + }, +}); + +export function createDirectMessageChannelDraft() { + return ChatChannel.create({ + isDraft: true, + chatable_type: CHATABLE_TYPES.directMessageChannel, + chatable: { + users: [], + }, + }); +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js new file mode 100644 index 00000000000..812eb8748db --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -0,0 +1,18 @@ +import RestModel from "discourse/models/rest"; +import User from "discourse/models/user"; + +export default class ChatMessage extends RestModel {} + +ChatMessage.reopenClass({ + create(args) { + args = args || {}; + this._initUserModel(args); + return this._super(args); + }, + + _initUserModel(args) { + if (args.user) { + args.user = User.create(args.user); + } + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js new file mode 100644 index 00000000000..8e97e26bfe2 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/models/user-chat-channel-membership.js @@ -0,0 +1,3 @@ +import RestModel from "discourse/models/rest"; + +export default class UserChatChannelMembership extends RestModel {} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js new file mode 100644 index 00000000000..51552575c94 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/emoji-picker-scroll-listener.js @@ -0,0 +1,23 @@ +import Modifier from "ember-modifier"; +import { inject as service } from "@ember/service"; +import { registerDestructor } from "@ember/destroyable"; + +export default class EmojiPickerScrollListener extends Modifier { + @service emojiPickerScrollObserver; + + element = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.element = element; + this.emojiPickerScrollObserver.observe(element); + } + + cleanup() { + this.emojiPickerScrollObserver.unobserve(this.element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js new file mode 100644 index 00000000000..10474b067cf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message-visibility.js @@ -0,0 +1,23 @@ +import Modifier from "ember-modifier"; +import { inject as service } from "@ember/service"; +import { registerDestructor } from "@ember/destroyable"; + +export default class TrackMessageVisibility extends Modifier { + @service chatMessageVisibilityObserver; + + element = null; + + constructor(owner, args) { + super(owner, args); + registerDestructor(this, (instance) => instance.cleanup()); + } + + modify(element) { + this.element = element; + this.chatMessageVisibilityObserver.observe(element); + } + + cleanup() { + this.chatMessageVisibilityObserver.unobserve(this.element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js b/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js new file mode 100644 index 00000000000..3a6801ea0b7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/pre-initializers/chat-plugin-api.js @@ -0,0 +1,37 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + addChatMessageDecorator, + resetChatMessageDecorators, +} from "discourse/plugins/chat/discourse/components/chat-message"; +import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +export default { + name: "chat-plugin-api", + after: "inject-discourse-objects", + + initialize() { + withPluginApi("1.2.0", (api) => { + const apiPrototype = Object.getPrototypeOf(api); + + if (!apiPrototype.hasOwnProperty("decorateChatMessage")) { + Object.defineProperty(apiPrototype, "decorateChatMessage", { + value(decorator) { + addChatMessageDecorator(decorator); + }, + }); + } + + if (!apiPrototype.hasOwnProperty("registerChatComposerButton")) { + Object.defineProperty(apiPrototype, "registerChatComposerButton", { + value(button) { + registerChatComposerButton(button); + }, + }); + } + }); + }, + + teardown() { + resetChatMessageDecorators(); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js b/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js new file mode 100644 index 00000000000..bc35fd60592 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/preferences-chat-route-map.js @@ -0,0 +1,7 @@ +export default { + resource: "user.preferences", + + map() { + this.route("chat"); + }, +}; diff --git a/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js b/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js new file mode 100644 index 00000000000..7255df34f12 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/admin-plugins-chat.js @@ -0,0 +1,24 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import EmberObject from "@ember/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { ajax } from "discourse/lib/ajax"; + +export default class AdminPluginsChatRoute extends DiscourseRoute { + model() { + if (!this.currentUser?.admin) { + return { model: null }; + } + + return ajax("/admin/plugins/chat.json").then((model) => { + model.incoming_chat_webhooks = model.incoming_chat_webhooks.map( + (webhook) => EmberObject.create(webhook) + ); + + model.chat_channels = model.chat_channels.map((channel) => { + return ChatChannel.create(channel); + }); + + return model; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js new file mode 100644 index 00000000000..367f4adadbf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-browse-index.js @@ -0,0 +1,14 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatBrowseIndexRoute extends DiscourseRoute { + @service chat; + + activate() { + this.chat.setActiveChannel(null); + } + + afterModel() { + this.replaceWith("chat.browse.open"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js new file mode 100644 index 00000000000..7516fe58bb1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-by-name.js @@ -0,0 +1,28 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelByNameRoute extends DiscourseRoute { + @service chat; + + async model(params) { + return ajax( + `/chat/chat_channels/${encodeURIComponent(params.channelName)}.json` + ) + .then((response) => { + this.transitionTo( + "chat.channel", + response.chat_channel.id, + response.chat_channel.title + ); + }) + .catch(() => this.replaceWith("/404")); + } + + beforeModel() { + if (!this.chat.userCanChat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js new file mode 100644 index 00000000000..181c9ffb690 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-about.js @@ -0,0 +1,9 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoAboutRoute extends DiscourseRoute { + afterModel(model) { + if (model.chatChannel.isDirectMessageChannel) { + this.replaceWith("chat.channel.info.index"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js new file mode 100644 index 00000000000..ffc3bc589b4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-index.js @@ -0,0 +1,15 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoIndexRoute extends DiscourseRoute { + afterModel(model) { + if (model.chatChannel.isDirectMessageChannel) { + if (model.chatChannel.isOpen && model.chatChannel.membershipsCount >= 1) { + this.replaceWith("chat.channel.info.members"); + } else { + this.replaceWith("chat.channel.info.settings"); + } + } else { + this.replaceWith("chat.channel.info.about"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js new file mode 100644 index 00000000000..25d2e4eb93a --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info-members.js @@ -0,0 +1,9 @@ +import DiscourseRoute from "discourse/routes/discourse"; + +export default class ChatChannelInfoMembersRoute extends DiscourseRoute { + afterModel(model) { + if (!model.chatChannel.isOpen) { + this.replaceWith("chat.channel.info.settings"); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js new file mode 100644 index 00000000000..3a167c4890f --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-info.js @@ -0,0 +1,22 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; + +export default class ChatChannelInfoRoute extends DiscourseRoute { + @service chatChannelInfoRouteOriginManager; + + activate(transition) { + const name = transition?.from?.name; + if (name) { + this.chatChannelInfoRouteOriginManager.origin = name.startsWith( + "chat.browse" + ) + ? ORIGINS.browse + : ORIGINS.channel; + } + } + + deactivate() { + this.chatChannelInfoRouteOriginManager.origin = null; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js new file mode 100644 index 00000000000..83f792c6820 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -0,0 +1,63 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import Promise from "rsvp"; +import EmberObject, { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { inject as service } from "@ember/service"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +export default class ChatChannelRoute extends DiscourseRoute { + @service chat; + + async model(params) { + let [chatChannel, channels] = await Promise.all([ + this.getChannel(params.channelId), + this.chat.getChannels(), + ]); + + return EmberObject.create({ + chatChannel, + channels, + }); + } + + async getChannel(id) { + let channel = await this.chat.getChannelBy("id", id); + if (!channel || this.forceRefetchChannel) { + channel = await this.getChannelFromServer(id); + } + return channel; + } + + async getChannelFromServer(id) { + return ajax(`/chat/chat_channels/${id}`) + .then((response) => ChatChannel.create(response)) + .catch(() => this.replaceWith("/404")); + } + + afterModel(model) { + this.appEvents.trigger("chat:navigated-to-full-page"); + this.chat.setActiveChannel(model?.chatChannel); + + const queryParams = this.paramsFor(this.routeName); + const slug = slugifyChannel(model.chatChannel.title); + if (queryParams?.channelTitle !== slug) { + this.replaceWith("chat.channel.index", model.chatChannel.id, slug); + } + } + + setupController(controller) { + super.setupController(...arguments); + + if (controller.messageId) { + this.chat.set("messageId", controller.messageId); + this.controller.set("messageId", null); + } + } + + @action + refreshModel(forceRefetchChannel = false) { + this.forceRefetchChannel = forceRefetchChannel; + this.refresh(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js new file mode 100644 index 00000000000..a5fd5df9df3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-draft-channel.js @@ -0,0 +1,16 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatDraftChannelRoute extends DiscourseRoute { + @service chat; + + beforeModel() { + if (!this.chat.userCanDirectMessage) { + this.transitionTo("chat"); + } + } + + activate() { + this.chat.setActiveChannel(null); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-index.js b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js new file mode 100644 index 00000000000..aeb6402be6d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-index.js @@ -0,0 +1,38 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { inject as service } from "@ember/service"; + +export default class ChatIndexRoute extends DiscourseRoute { + @service chat; + + beforeModel() { + if (this.site.mobileView) { + return; // Always want the channel index on mobile. + } + + // We are on desktop. Check for a channel to enter and transition if so. + // Otherwise, `setupController` will fetch all available + return this.chat.getIdealFirstChannelIdAndTitle().then((channelInfo) => { + if (channelInfo) { + return this.chat.getChannelBy("id", channelInfo.id).then((c) => { + this.chat.openChannel(c); + return; + }); + } else { + return this.transitionTo("chat.browse"); + } + }); + } + + model() { + if (this.site.mobileView) { + return this.chat.getChannels().then((channels) => { + if ( + channels.publicChannels.length || + channels.directMessageChannels.length + ) { + return channels; + } + }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-message.js b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js new file mode 100644 index 00000000000..c96d913c092 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-message.js @@ -0,0 +1,29 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { ajax } from "discourse/lib/ajax"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; + +export default class ChatMessageRoute extends DiscourseRoute { + @service chat; + + async model(params) { + return ajax(`/chat/message/${params.messageId}.json`) + .then((response) => { + this.transitionTo( + "chat.channel", + response.chat_channel_id, + response.chat_channel_title, + { + queryParams: { messageId: params.messageId }, + } + ); + }) + .catch(() => this.replaceWith("/404")); + } + + beforeModel() { + if (!this.chat.userCanChat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js new file mode 100644 index 00000000000..cfda809d283 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -0,0 +1,43 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; +import { scrollTop } from "discourse/mixins/scroll-top"; +import { schedule } from "@ember/runloop"; + +export default class ChatRoute extends DiscourseRoute { + @service chat; + @service router; + @service fullPageChat; + + titleToken() { + return I18n.t("chat.title_capitalized"); + } + + beforeModel(transition) { + if (!this.chat.userCanChat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + + this.fullPageChat.enter(transition?.from); + } + + activate() { + this.chat.updatePresence(); + + schedule("afterRender", () => { + document.body.classList.add("has-full-page-chat"); + document.documentElement.classList.add("has-full-page-chat"); + }); + } + + deactivate() { + this.fullPageChat.exit(); + this.chat.setActiveChannel(null); + schedule("afterRender", () => { + document.body.classList.remove("has-full-page-chat"); + document.documentElement.classList.remove("has-full-page-chat"); + scrollTop(); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js new file mode 100644 index 00000000000..31e92c27fc6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/routes/preferences-chat.js @@ -0,0 +1,16 @@ +import RestrictedUserRoute from "discourse/routes/restricted-user"; +import { defaultHomepage } from "discourse/lib/utilities"; +import { inject as service } from "@ember/service"; + +export default class PreferencesChatRoute extends RestrictedUserRoute { + @service chat; + + showFooter = true; + + setupController(controller, user) { + if (!user?.can_chat) { + return this.transitionTo(`discovery.${defaultHomepage()}`); + } + controller.set("model", user); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js new file mode 100644 index 00000000000..7ed6c2376a1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-info-route-origin-manager.js @@ -0,0 +1,34 @@ +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; +import { isEmpty } from "@ember/utils"; + +export const BACK_KEY = "back"; +export const INFO_ROUTE_NAMESPACE = "discourse_chat_info_route"; +export const ORIGINS = { + channel: "channel", + browse: "browse", +}; + +export default class ChatChannelInfoRouteOriginManager extends Service { + store = new KeyValueStore(INFO_ROUTE_NAMESPACE); + + get origin() { + const origin = this.store.getObject(BACK_KEY); + + if (origin) { + return ORIGINS[origin]; + } + } + + set origin(value) { + this.store.setObject({ key: BACK_KEY, value }); + } + + get isBrowse() { + return this.origin === ORIGINS.browse; + } + + get isChannel() { + return this.origin === ORIGINS.channel || isEmpty(this.origin); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js new file mode 100644 index 00000000000..57ddd682dbf --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-composer-presence-manager.js @@ -0,0 +1,56 @@ +import Service, { inject as service } from "@ember/service"; +import { cancel, debounce } from "@ember/runloop"; +import { isTesting } from "discourse-common/config/environment"; + +const CHAT_PRESENCE_CHANNEL_PREFIX = "/chat-reply"; +const KEEP_ALIVE_DURATION_SECONDS = 10; + +// This service is loosely based on discourse-presence's ComposerPresenceManager service +// It is a singleton which receives notifications each time the value of the chat composer changes +// This service ensures that a single browser can only be 'replying' to a single chatChannel at +// one time, and automatically 'leaves' the channel if the composer value hasn't changed for 10 seconds +export default class ChatComposerPresenceManager extends Service { + @service presence; + + willDestroy() { + this.leave(); + } + + notifyState(chatChannelId, replying) { + if (!replying) { + this.leave(); + return; + } + + if (this._chatChannelId !== chatChannelId) { + this._enter(chatChannelId); + this._chatChannelId = chatChannelId; + } + + if (!isTesting()) { + this._autoLeaveTimer = debounce( + this, + this.leave, + KEEP_ALIVE_DURATION_SECONDS * 1000 + ); + } + } + + leave() { + this._presentChannel?.leave(); + this._presentChannel = null; + this._chatChannelId = null; + if (this._autoLeaveTimer) { + cancel(this._autoLeaveTimer); + this._autoLeaveTimer = null; + } + } + + _enter(chatChannelId) { + this.leave(); + + let channelName = `${CHAT_PRESENCE_CHANNEL_PREFIX}/${chatChannelId}`; + this._presentChannel = this.presence.getChannel(channelName); + this._presentChannel.enter(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js new file mode 100644 index 00000000000..48e2b9ef515 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js @@ -0,0 +1,149 @@ +import { headerOffset } from "discourse/lib/offset-calculator"; +import { createPopper } from "@popperjs/core"; +import Service from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse-common/utils/decorators"; +import { later, schedule } from "@ember/runloop"; +import { makeArray } from "discourse-common/lib/helpers"; +import { Promise } from "rsvp"; +import { computed } from "@ember/object"; +import { isTesting } from "discourse-common/config/environment"; + +const TRANSITION_TIME = isTesting() ? 0 : 125; // CSS transition time +const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"]; +const DEFAULT_LAST_SECTION = "favorites"; + +export default class ChatEmojiPickerManager extends Service { + @tracked opened = false; + @tracked closing = false; + @tracked loading = false; + @tracked context = null; + @tracked emojis = null; + @tracked visibleSections = DEFAULT_VISIBLE_SECTIONS; + @tracked lastVisibleSection = DEFAULT_LAST_SECTION; + @tracked element = null; + @tracked callback; + + @computed("emojis.[]", "loading") + get sections() { + return !this.loading && this.emojis ? Object.keys(this.emojis) : []; + } + + @bind + closeExisting() { + this.callback = null; + this.opened = false; + this.visibleSections = DEFAULT_VISIBLE_SECTIONS; + this.lastVisibleSection = DEFAULT_LAST_SECTION; + } + + @bind + close() { + this.callback = null; + this.closing = true; + + later(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.visibleSections = DEFAULT_VISIBLE_SECTIONS; + this.lastVisibleSection = DEFAULT_LAST_SECTION; + this.closing = false; + this.opened = false; + }, TRANSITION_TIME); + } + + addVisibleSections(sections) { + this.visibleSections = makeArray(this.visibleSections) + .concat(makeArray(sections)) + .uniq(); + } + + didSelectEmoji(emoji) { + this?.callback(emoji); + this.callback = null; + this.close(); + } + + startFromMessageReactionList(message, isDesktop, callback) { + const trigger = document.querySelector( + `.chat-message-container[data-id="${message.id}"] .chat-message-react-btn` + ); + this.startFromMessage(callback, isDesktop, trigger); + } + + startFromMessageActions(message, isDesktop, callback) { + const trigger = document.querySelector( + `.chat-msgactions-hover[data-id="${message.id}"] .chat-msgactions` + ); + this.startFromMessage(callback, isDesktop, trigger); + } + + startFromMessage(callback, isDesktop, trigger) { + this.context = "chat-message"; + this.element = document.querySelector(".chat-message-emoji-picker-anchor"); + this.open(callback); + this._popper?.destroy(); + + if (isDesktop) { + schedule("afterRender", () => { + this._popper = createPopper(trigger, this.element, { + placement: "top", + modifiers: [ + { + name: "eventListeners", + options: { + scroll: false, + resize: false, + }, + }, + { + name: "flip", + options: { + padding: { top: headerOffset() }, + }, + }, + ], + }); + }); + } + } + + startFromComposer(callback) { + this.context = "chat-composer"; + this.element = document.querySelector(".chat-composer-emoji-picker-anchor"); + this.open(callback); + } + + open(callback) { + if (this.opened) { + this.closeExisting(); + } + + this._loadEmojisData(); + + this.callback = callback; + this.opened = true; + } + + _loadEmojisData() { + if (this.emojis) { + return Promise.resolve(); + } + + this.loading = true; + + return ajax("/chat/emojis.json") + .then((emojis) => { + this.emojis = emojis; + }) + + .catch(popupAjaxError) + .finally(() => { + this.loading = false; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js new file mode 100644 index 00000000000..fbadb19b8d3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-reaction-store.js @@ -0,0 +1,88 @@ +// This class is adapted from emoji-store class in core. We want to maintain separate emoji store for reactions in chat plugin. +// https://github.com/discourse/discourse/blob/892f7e0506f3a4d40d9a59a4c926ff0a2aa0947e/app/assets/javascripts/discourse/app/services/emoji-store.js + +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; + +export default class ChatEmojiReactionStore extends Service { + STORE_NAMESPACE = "discourse_chat_emoji_reaction_"; + MAX_DISPLAYED_EMOJIS = 20; + MAX_TRACKED_EMOJIS = this.MAX_DISPLAYED_EMOJIS * 2; + SKIN_TONE_STORE_KEY = "emojiSelectedDiversity"; + USER_EMOJIS_STORE_KEY = "emojiUsage"; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + constructor() { + super(...arguments); + + if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) { + this.storedFavorites = []; + } + } + + get diversity() { + return this.store.getObject(this.SKIN_TONE_STORE_KEY) || 1; + } + + set diversity(value = 1) { + this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value }); + this.notifyPropertyChange("diversity"); + } + + get storedFavorites() { + let value = this.store.getObject(this.USER_EMOJIS_STORE_KEY) || []; + + if (value.length < 1) { + if (!this.siteSettings.default_emoji_reactions) { + value = []; + } else { + value = this.siteSettings.default_emoji_reactions + .split("|") + .filter(Boolean); + } + + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + } + + return value; + } + + set storedFavorites(value) { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + this.notifyPropertyChange("favorites"); + } + + get favorites() { + const computedStored = [ + ...new Set(this._frequencySort(this.storedFavorites)), + ]; + + return computedStored.slice(0, this.MAX_DISPLAYED_EMOJIS); + } + + set favorites(value = []) { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value }); + } + + track(code) { + const normalizedCode = code.replace(/(^:)|(:$)/g, ""); + let recent = this.storedFavorites; + recent.unshift(normalizedCode); + recent.length = Math.min(recent.length, this.MAX_TRACKED_EMOJIS); + this.storedFavorites = recent; + } + + reset() { + this.store.setObject({ key: this.USER_EMOJIS_STORE_KEY, value: [] }); + this.store.setObject({ key: this.SKIN_TONE_STORE_KEY, value: 1 }); + } + + _frequencySort(array = []) { + const counters = array.reduce((obj, val) => { + obj[val] = (obj[val] || 0) + 1; + return obj; + }, {}); + return Object.keys(counters).sort((a, b) => counters[b] - counters[a]); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js b/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js new file mode 100644 index 00000000000..791b06938e6 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-guardian.js @@ -0,0 +1,22 @@ +import Service from "@ember/service"; + +export default class ChatGuardian extends Service { + canEditChatChannel() { + return this.canUseChat() && this.currentUser.staff; + } + + canArchiveChannel(channel) { + return ( + this.canEditChatChannel() && + this.siteSettings.chat_allow_archiving_channels && + !channel.isArchived && + !channel.isReadOnly + ); + } + + canUseChat() { + return ( + this.currentUser?.has_chat_enabled && this.siteSettings?.chat_enabled + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js new file mode 100644 index 00000000000..b76f8420e44 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-message-visibility-observer.js @@ -0,0 +1,35 @@ +import Service, { inject as service } from "@ember/service"; +import { isTesting } from "discourse-common/config/environment"; +import { bind } from "discourse-common/utils/decorators"; + +export default class ChatMessageVisibilityObserver extends Service { + @service chat; + + observer = new IntersectionObserver(this._observerCallback, { + root: document, + rootMargin: "-10px", + }); + + willDestroy() { + this.observer.disconnect(); + } + + @bind + _observerCallback(entries) { + entries.forEach((entry) => { + entry.target.dataset.visible = entry.isIntersecting; + + if (entry.isIntersecting && !isTesting()) { + this.chat.updateLastReadMessage(); + } + }); + } + + observe(element) { + this.observer.observe(element); + } + + unobserve(element) { + this.observer.unobserve(element); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js new file mode 100644 index 00000000000..da8b500cafa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-notification-manager.js @@ -0,0 +1,135 @@ +import Service, { inject as service } from "@ember/service"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { isTesting } from "discourse-common/config/environment"; +import { + alertChannel, + onNotification, +} from "discourse/lib/desktop-notifications"; +import { bind, observes } from "discourse-common/utils/decorators"; + +export default class ChatNotificationManager extends Service { + @service presence; + @service chat; + + _inChat = false; + _subscribedToCore = true; + _subscribedToChat = false; + _countChatInDocTitle = true; + + start() { + if (!this._shouldRun()) { + return; + } + + this.set( + "_chatPresenceChannel", + this.presence.getChannel(`/chat-user/chat/${this.currentUser.id}`) + ); + this.set( + "_corePresenceChannel", + this.presence.getChannel(`/chat-user/core/${this.currentUser.id}`) + ); + this._chatPresenceChannel.subscribe(); + this._corePresenceChannel.subscribe(); + + withPluginApi("0.12.1", (api) => { + api.onPageChange(this._pageChanged); + }); + } + + willDestroy() { + super.willDestroy(...arguments); + + if (!this._shouldRun()) { + return; + } + + this._chatPresenceChannel.unsubscribe(); + this._chatPresenceChannel.leave(); + this._corePresenceChannel.unsubscribe(); + this._corePresenceChannel.leave(); + } + + shouldCountChatInDocTitle() { + return this._countChatInDocTitle; + } + + @bind + _pageChanged(path) { + this.set("_inChat", path.startsWith("/chat/channel/")); + if (this._inChat) { + this._chatPresenceChannel.enter({ onlyWhileActive: false }); + this._corePresenceChannel.leave(); + } else { + this._chatPresenceChannel.leave(); + this._corePresenceChannel.enter({ onlyWhileActive: false }); + } + } + + @observes("_chatPresenceChannel.count", "_corePresenceChannel.count") + _channelCountsChanged() { + discourseDebounce(this, this._subscribeToCorrectNotifications, 2000); + } + + _coreAlertChannel() { + return alertChannel(this.currentUser); + } + + _chatAlertChannel() { + return `/chat${alertChannel(this.currentUser)}`; + } + + _subscribeToCorrectNotifications() { + const oneTabForEachOpen = + this._chatPresenceChannel.count > 0 && + this._corePresenceChannel.count > 0; + if (oneTabForEachOpen) { + this._inChat + ? this._subscribeToChat({ only: true }) + : this._subscribeToCore({ only: true }); + } else { + this._subscribeToBoth(); + } + } + + _subscribeToBoth() { + this._subscribeToChat(); + this._subscribeToCore(); + } + + _subscribeToChat(opts = { only: false }) { + this.set("_countChatInDocTitle", true); + + if (!this._subscribedToChat) { + this.messageBus.subscribe(this._chatAlertChannel(), (data) => + onNotification(data, this.siteSettings, this.currentUser) + ); + } + + if (opts.only && this._subscribedToCore) { + this.messageBus.unsubscribe(this._coreAlertChannel()); + this.set("_subscribedToCore", false); + } + } + + _subscribeToCore(opts = { only: false }) { + if (opts.only) { + this.set("_countChatInDocTitle", false); + } + if (!this._subscribedToCore) { + this.messageBus.subscribe(this._coreAlertChannel(), (data) => + onNotification(data, this.siteSettings, this.currentUser) + ); + } + + if (this.only && this._subscribedToChat) { + this.messageBus.unsubscribe(this._chatAlertChannel()); + this.set("_subscribedToChat", false); + } + } + + _shouldRun() { + return this.chat.userCanChat && !isTesting(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js new file mode 100644 index 00000000000..86c98ec0720 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -0,0 +1,995 @@ +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import deprecated from "discourse-common/lib/deprecated"; +import userSearch from "discourse/lib/user-search"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import Service, { inject as service } from "@ember/service"; +import Site from "discourse/models/site"; +import { ajax } from "discourse/lib/ajax"; +import { A } from "@ember/array"; +import { generateCookFunction } from "discourse/lib/text"; +import { cancel, next } from "@ember/runloop"; +import { and } from "@ember/object/computed"; +import { Promise } from "rsvp"; +import ChatChannel, { + CHANNEL_STATUSES, + CHATABLE_TYPES, +} from "discourse/plugins/chat/discourse/models/chat-channel"; +import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; +import discourseDebounce from "discourse-common/lib/debounce"; +import EmberObject, { computed } from "@ember/object"; +import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api"; +import discourseLater from "discourse-common/lib/later"; +import userPresent from "discourse/lib/user-presence"; + +export const LIST_VIEW = "list_view"; +export const CHAT_VIEW = "chat_view"; +export const DRAFT_CHANNEL_VIEW = "draft_channel_view"; + +const CHAT_ONLINE_OPTIONS = { + userUnseenTime: 300000, // 5 minutes seconds with no interaction + browserHiddenTime: 300000, // Or the browser has been in the background for 5 minutes +}; + +const READ_INTERVAL = 1000; + +export default class Chat extends Service { + @service appEvents; + @service chatNotificationManager; + @service fullPageChat; + @service presence; + @service router; + @service site; + + activeChannel = null; + allChannels = null; + cook = null; + directMessageChannels = null; + hasFetchedChannels = false; + hasUnreadMessages = false; + idToTitleMap = null; + lastUserTrackingMessageId = null; + messageId = null; + presenceChannel = null; + publicChannels = null; + sidebarActive = false; + unreadUrgentCount = null; + directMessagesLimit = 20; + isNetworkUnreliable = false; + @and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat; + _chatOpen = false; + _fetchingChannels = null; + + @computed("currentUser.staff", "currentUser.groups.[]") + get userCanDirectMessage() { + if (!this.currentUser) { + return false; + } + + return ( + this.currentUser.staff || + this.currentUser.isInAnyGroups( + (this.siteSettings.direct_message_enabled_groups || "11") // trust level 1 auto group + .split("|") + .map((groupId) => parseInt(groupId, 10)) + ) + ); + } + + init() { + super.init(...arguments); + + if (this.userCanChat) { + this.set("allChannels", []); + this._subscribeToNewChannelUpdates(); + this._subscribeToUserTrackingChannel(); + this._subscribeToChannelEdits(); + this._subscribeToChannelMetadata(); + this._subscribeToChannelStatusChange(); + this.presenceChannel = this.presence.getChannel("/chat/online"); + this.draftStore = {}; + + if (this.currentUser.chat_drafts) { + this.currentUser.chat_drafts.forEach((draft) => { + this.draftStore[draft.channel_id] = JSON.parse(draft.data); + }); + } + } + } + + markNetworkAsUnreliable() { + cancel(this._networkCheckHandler); + + this.set("isNetworkUnreliable", true); + + this._networkCheckHandler = discourseLater(() => { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.markNetworkAsReliable(); + }, 30000); + } + + markNetworkAsReliable() { + cancel(this._networkCheckHandler); + + this.set("isNetworkUnreliable", false); + } + + setupWithPreloadedChannels(channels) { + this.currentUser.set("chat_channel_tracking_state", {}); + this._processChannels(channels || {}); + this.userChatChannelTrackingStateChanged(); + this.appEvents.trigger("chat:refresh-channels"); + } + + willDestroy() { + super.willDestroy(...arguments); + + if (this.userCanChat) { + this.set("allChannels", null); + this._unsubscribeFromNewDmChannelUpdates(); + this._unsubscribeFromUserTrackingChannel(); + this._unsubscribeFromChannelEdits(); + this._unsubscribeFromChannelMetadata(); + this._unsubscribeFromChannelStatusChange(); + this._unsubscribeFromAllChatChannels(); + } + } + + setActiveChannel(channel) { + this.set("activeChannel", channel); + } + + loadCookFunction(categories) { + if (this.cook) { + return Promise.resolve(this.cook); + } + + const prettyTextFeatures = { + featuresOverride: Site.currentProp( + "markdown_additional_options.chat.limited_pretty_text_features" + ), + markdownItRules: Site.currentProp( + "markdown_additional_options.chat.limited_pretty_text_markdown_rules" + ), + }; + + return generateCookFunction(prettyTextFeatures).then((cookFunction) => { + return this.set("cook", (raw) => { + return simpleCategoryHashMentionTransform( + cookFunction(raw), + categories + ); + }); + }); + } + + get chatOpen() { + return this._chatOpen; + } + + set chatOpen(status) { + this.set("_chatOpen", status); + this.updatePresence(); + } + + updatePresence() { + next(() => { + if (this.fullPageChat.isActive || this.chatOpen) { + this.presenceChannel.enter({ activeOptions: CHAT_ONLINE_OPTIONS }); + } else { + this.presenceChannel.leave(); + } + }); + } + + getDocumentTitleCount() { + return this.chatNotificationManager.shouldCountChatInDocTitle() + ? this.unreadUrgentCount + : 0; + } + + _channelObject() { + return { + publicChannels: this.publicChannels, + directMessageChannels: this.directMessageChannels, + }; + } + + truncateDirectMessageChannels(channels) { + return channels.slice(0, this.directMessagesLimit); + } + + getActiveChannel() { + let channelId; + if (this.router.currentRouteName === "chat.channel") { + channelId = this.router.currentRoute.params.channelId; + } else { + channelId = document.querySelector(".topic-chat-container.visible") + ?.dataset?.chatChannelId; + } + return channelId + ? this.allChannels.findBy("id", parseInt(channelId, 10)) + : null; + } + + async getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) { + let sortedChannels = this.allChannels.sort((a, b) => { + return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at) + ? -1 + : 1; + }); + + const trimmedFilter = filter.trim(); + const lowerCasedFilter = filter.toLowerCase(); + const { activeChannel } = this; + + return sortedChannels.filter((channel) => { + if ( + opts.excludeActiveChannel && + activeChannel && + activeChannel.id === channel.id + ) { + return false; + } + if (!trimmedFilter.length) { + return true; + } + + if (channel.isDirectMessageChannel) { + let userFound = false; + channel.chatable.users.forEach((user) => { + if ( + user.username.toLowerCase().includes(lowerCasedFilter) || + user.name?.toLowerCase().includes(lowerCasedFilter) + ) { + return (userFound = true); + } + }); + return userFound; + } else { + return channel.title.toLowerCase().includes(lowerCasedFilter); + } + }); + } + + switchChannelUpOrDown(direction) { + const { activeChannel } = this; + if (!activeChannel) { + return; // Chat isn't open. Return and do nothing! + } + + let currentList, otherList; + if (activeChannel.isDirectMessageChannel) { + currentList = this.truncateDirectMessageChannels( + this.directMessageChannels + ); + otherList = this.publicChannels; + } else { + currentList = this.publicChannels; + otherList = this.truncateDirectMessageChannels( + this.directMessageChannels + ); + } + + const directionUp = direction === "up"; + const currentChannelIndex = currentList.findIndex( + (c) => c.id === activeChannel.id + ); + + let nextChannelInSameList = + currentList[currentChannelIndex + (directionUp ? -1 : 1)]; + if (nextChannelInSameList) { + // You're navigating in the same list of channels, just use index +- 1 + return this.openChannel(nextChannelInSameList); + } + + // You need to go to the next list of channels, if it exists. + const nextList = otherList.length ? otherList : currentList; + const nextChannel = directionUp + ? nextList[nextList.length - 1] + : nextList[0]; + + if (nextChannel.id !== activeChannel.id) { + return this.openChannel(nextChannel); + } + } + + getChannels() { + return new Promise((resolve) => { + if (this.hasFetchedChannels) { + return resolve(this._channelObject()); + } + + if (!this._fetchingChannels) { + this._fetchingChannels = this._refreshChannels(); + } + + this._fetchingChannels + .then(() => resolve(this._channelObject())) + .finally(() => (this._fetchingChannels = null)); + }); + } + + forceRefreshChannels() { + this.set("hasFetchedChannels", false); + this._unsubscribeFromAllChatChannels(); + return this.getChannels(); + } + + refreshTrackingState() { + if (!this.currentUser) { + return; + } + + return ajax("/chat/chat_channels.json") + .then((response) => { + this.currentUser.set("chat_channel_tracking_state", {}); + (response.direct_message_channels || []).forEach((channel) => { + this._updateUserTrackingState(channel); + }); + (response.public_channels || []).forEach((channel) => { + this._updateUserTrackingState(channel); + }); + }) + .finally(() => { + this.userChatChannelTrackingStateChanged(); + }); + } + + _refreshChannels() { + return new Promise((resolve) => { + this.setProperties({ + loading: true, + allChannels: [], + }); + this.currentUser.set("chat_channel_tracking_state", {}); + ajax("/chat/chat_channels.json").then((channels) => { + this._processChannels(channels); + this.userChatChannelTrackingStateChanged(); + this.appEvents.trigger("chat:refresh-channels"); + resolve(this._channelObject()); + }); + }); + } + + _processChannels(channels) { + this.setProperties({ + publicChannels: A( + this.sortPublicChannels( + (channels.public_channels || []).map((channel) => + this.processChannel(channel) + ) + ) + ), + directMessageChannels: A( + this.sortDirectMessageChannels( + (channels.direct_message_channels || []).map((channel) => + this.processChannel(channel) + ) + ) + ), + hasFetchedChannels: true, + loading: false, + }); + const idToTitleMap = {}; + this.allChannels.forEach((c) => { + idToTitleMap[c.id] = c.title; + }); + this.set("idToTitleMap", idToTitleMap); + this.presenceChannel.subscribe(channels.global_presence_channel_state); + } + + reSortDirectMessageChannels() { + this.set( + "directMessageChannels", + this.sortDirectMessageChannels(this.directMessageChannels) + ); + } + + async getChannelBy(key, value) { + return this.getChannels().then(() => { + if (!isNaN(value)) { + value = parseInt(value, 10); + } + return (this.allChannels || []).findBy(key, value); + }); + } + + searchPossibleDirectMessageUsers(options) { + // TODO: implement a chat specific user search function + return userSearch(options); + } + + getIdealFirstChannelId() { + // When user opens chat we need to give them the 'best' channel when they enter. + // + // Look for public channels with mentions. If one exists, enter that. + // Next best is a DM channel with unread messages. + // Next best is a public channel with unread messages. + // Then we fall back to the chat_default_channel_id site setting + // if that is present and in the list of channels the user can access. + // If none of these options exist, then we get the first public channel, + // or failing that the first DM channel. + return this.getChannels().then(() => { + // Defined in order of significance. + let publicChannelWithMention, + dmChannelWithUnread, + publicChannelWithUnread, + publicChannel, + dmChannel, + defaultChannel; + + for (const [channel, state] of Object.entries( + this.currentUser.chat_channel_tracking_state + )) { + if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) { + if (!dmChannelWithUnread && state.unread_count > 0) { + dmChannelWithUnread = channel; + } else if (!dmChannel) { + dmChannel = channel; + } + } else { + if (state.unread_mentions > 0) { + publicChannelWithMention = channel; + break; // <- We have a public channel with a mention. Break and return this. + } else if (!publicChannelWithUnread && state.unread_count > 0) { + publicChannelWithUnread = channel; + } else if ( + !defaultChannel && + parseInt(this.siteSettings.chat_default_channel_id || 0, 10) === + parseInt(channel, 10) + ) { + defaultChannel = channel; + } else if (!publicChannel) { + publicChannel = channel; + } + } + } + return ( + publicChannelWithMention || + dmChannelWithUnread || + publicChannelWithUnread || + defaultChannel || + publicChannel || + dmChannel + ); + }); + } + + sortPublicChannels(channels) { + return channels.sort((a, b) => a.title.localeCompare(b.title)); + } + + sortDirectMessageChannels(channels) { + return channels.sort((a, b) => { + const unreadCountA = + this.currentUser.chat_channel_tracking_state[a.id]?.unread_count || 0; + const unreadCountB = + this.currentUser.chat_channel_tracking_state[b.id]?.unread_count || 0; + if (unreadCountA === unreadCountB) { + return new Date(a.last_message_sent_at) > + new Date(b.last_message_sent_at) + ? -1 + : 1; + } else { + return unreadCountA > unreadCountB ? -1 : 1; + } + }); + } + + getIdealFirstChannelIdAndTitle() { + return this.getIdealFirstChannelId().then((channelId) => { + if (!channelId) { + return; + } + return { + id: channelId, + title: this.idToTitleMap[channelId], + }; + }); + } + + async openChannelAtMessage(channelId, messageId = null) { + let channel = await this.getChannelBy("id", channelId); + if (channel) { + return this._openFoundChannelAtMessage(channel, messageId); + } + + return ajax(`/chat/chat_channels/${channelId}`).then((response) => { + const queryParams = messageId ? { messageId } : {}; + return this.router.transitionTo( + "chat.channel", + response.id, + slugifyChannel(response.title), + { queryParams } + ); + }); + } + + async openChannel(channel) { + return this._openFoundChannelAtMessage(channel); + } + + async _openFoundChannelAtMessage(channel, messageId = null) { + if ( + this.router.currentRouteName === "chat.channel.index" && + this.activeChannel?.id === channel.id + ) { + this.setActiveChannel(channel); + this._fireOpenMessageAppEvent(messageId); + return Promise.resolve(); + } + + this.setActiveChannel(channel); + + if ( + this.fullPageChat.isActive || + this.site.mobileView || + this.fullPageChat.isPreferred + ) { + const queryParams = messageId ? { messageId } : {}; + + return this.router.transitionTo( + "chat.channel", + channel.id, + slugifyChannel(channel.title), + { queryParams } + ); + } else { + this._fireOpenFloatAppEvent(channel, messageId); + return Promise.resolve(); + } + } + + _fireOpenFloatAppEvent(channel, messageId = null) { + messageId + ? this.appEvents.trigger( + "chat:open-channel-at-message", + channel, + messageId + ) + : this.appEvents.trigger("chat:open-channel", channel); + } + + _fireOpenMessageAppEvent(messageId) { + this.appEvents.trigger("chat-live-pane:highlight-message", messageId); + } + + async startTrackingChannel(channel) { + if (!channel.current_user_membership.following) { + return; + } + + let existingChannel = await this.getChannelBy("id", channel.id); + if (existingChannel) { + return existingChannel; // User is already tracking this channel. return! + } + + const existingChannels = channel.isDirectMessageChannel + ? this.directMessageChannels + : this.publicChannels; + + // this check shouldn't be needed given the previous check to existingChannel + // this is a safety net, to ensure we never track duplicated channels + existingChannel = existingChannels.findBy("id", channel.id); + if (existingChannel) { + return existingChannel; + } + + const newChannel = this.processChannel(channel); + existingChannels.pushObject(newChannel); + this.currentUser.chat_channel_tracking_state[channel.id] = + EmberObject.create({ + unread_count: 1, + unread_mentions: 0, + chatable_type: channel.chatable_type, + }); + this.userChatChannelTrackingStateChanged(); + if (channel.isDirectMessageChannel) { + this.reSortDirectMessageChannels(); + } + if (channel.isPublicChannel) { + this.set("publicChannels", this.sortPublicChannels(this.publicChannels)); + } + this.appEvents.trigger("chat:refresh-channels"); + return newChannel; + } + + async stopTrackingChannel(channel) { + return this.getChannelBy("id", channel.id).then((existingChannel) => { + if (existingChannel) { + return this.forceRefreshChannels(); + } + }); + } + + _subscribeToChannelMetadata() { + this.messageBus.subscribe("/chat/channel-metadata", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (channel) { + channel.setProperties({ + memberships_count: busData.memberships_count, + }); + this.appEvents.trigger("chat:refresh-channel-members"); + } + }); + }); + } + + _subscribeToChannelEdits() { + this.messageBus.subscribe("/chat/channel-edits", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (channel) { + channel.setProperties({ + title: busData.name, + description: busData.description, + }); + } + }); + }); + } + + _subscribeToChannelStatusChange() { + this.messageBus.subscribe("/chat/channel-status", (busData) => { + this.getChannelBy("id", busData.chat_channel_id).then((channel) => { + if (!channel) { + return; + } + + channel.set("status", busData.status); + + // it is not possible for the user to set their last read message id + // if the channel has been archived, because all the messages have + // been deleted. we don't want them seeing the blue dot anymore so + // just completely reset the unreads + if (busData.status === CHANNEL_STATUSES.archived) { + this.currentUser.chat_channel_tracking_state[channel.id] = { + unread_count: 0, + unread_mentions: 0, + chatable_type: channel.chatable_type, + }; + this.userChatChannelTrackingStateChanged(); + } + + this.appEvents.trigger("chat:refresh-channel", channel.id); + }); + }); + } + + _unsubscribeFromChannelStatusChange() { + this.messageBus.unsubscribe("/chat/channel-status"); + } + + _unsubscribeFromChannelEdits() { + this.messageBus.unsubscribe("/chat/channel-edits"); + } + + _unsubscribeFromChannelMetadata() { + this.messageBus.unsubscribe("/chat/channel-metadata"); + } + + _subscribeToNewChannelUpdates() { + this.messageBus.subscribe("/chat/new-channel", (busData) => { + this.startTrackingChannel(ChatChannel.create(busData.chat_channel)); + }); + } + + _unsubscribeFromNewDmChannelUpdates() { + this.messageBus.unsubscribe("/chat/new-channel"); + } + + _subscribeToSingleUpdateChannel(channel) { + if (channel.current_user_membership.muted) { + return; + } + + if (!channel.isDirectMessageChannel) { + this._subscribeToMentionChannel(channel); + } + + this.messageBus.subscribe(`/chat/${channel.id}/new-messages`, (busData) => { + const trackingState = + this.currentUser.chat_channel_tracking_state[channel.id]; + + if (busData.user_id === this.currentUser.id) { + // User sent message, update tracking state to no unread + trackingState.set("chat_message_id", busData.message_id); + } else { + // Ignored user sent message, update tracking state to no unread + if (this.currentUser.ignored_users.includes(busData.username)) { + trackingState.set("chat_message_id", busData.message_id); + } else { + // Message from other user. Increment trackings state + if (busData.message_id > (trackingState.chat_message_id || 0)) { + trackingState.set("unread_count", trackingState.unread_count + 1); + } + } + } + this.userChatChannelTrackingStateChanged(); + + // Update last_message_sent_at timestamp for channel if direct message + const dmChatChannel = (this.directMessageChannels || []).findBy( + "id", + parseInt(channel.id, 10) + ); + if (dmChatChannel) { + dmChatChannel.set("last_message_sent_at", new Date()); + this.reSortDirectMessageChannels(); + } + }); + } + + _subscribeToMentionChannel(channel) { + this.messageBus.subscribe(`/chat/${channel.id}/new-mentions`, () => { + const trackingState = + this.currentUser.chat_channel_tracking_state[channel.id]; + if (trackingState) { + trackingState.set( + "unread_mentions", + (trackingState.unread_mentions || 0) + 1 + ); + this.userChatChannelTrackingStateChanged(); + } + }); + } + + async followChannel(channel) { + return ChatApi.followChatChannel(channel).then(() => { + this.startTrackingChannel(channel); + this._subscribeToSingleUpdateChannel(channel); + }); + } + + async unfollowChannel(channel) { + return ChatApi.unfollowChatChannel(channel).then(() => { + this._unsubscribeFromChatChannel(channel); + this.stopTrackingChannel(channel); + + if (channel.isDirectMessageChannel) { + this.router.transitionTo("chat"); + } + }); + } + + _unsubscribeFromAllChatChannels() { + (this.allChannels || []).forEach((channel) => { + this._unsubscribeFromChatChannel(channel); + }); + } + + _unsubscribeFromChatChannel(channel) { + this.messageBus.unsubscribe(`/chat/${channel.id}/new-messages`); + if (!channel.isDirectMessageChannel) { + this.messageBus.unsubscribe(`/chat/${channel.id}/new-mentions`); + } + } + + _subscribeToUserTrackingChannel() { + this.messageBus.subscribe( + `/chat/user-tracking-state/${this.currentUser.id}`, + (busData, _, messageId) => { + const lastId = this.lastUserTrackingMessageId; + + // we don't want this state to go backwards, only catch + // up if messages from messagebus were missed + if (!lastId || messageId > lastId) { + this.lastUserTrackingMessageId = messageId; + } + + // we are too far out of sync, we should resync everything. + // this will trigger a route transition and blur the chat input + if (lastId && messageId > lastId + 1) { + return this.forceRefreshChannels(); + } + + const trackingState = + this.currentUser.chat_channel_tracking_state[busData.chat_channel_id]; + if (trackingState) { + trackingState.set("chat_message_id", busData.chat_message_id); + trackingState.set("unread_count", 0); + trackingState.set("unread_mentions", 0); + this.userChatChannelTrackingStateChanged(); + } + } + ); + } + + _unsubscribeFromUserTrackingChannel() { + this.messageBus.unsubscribe( + `/chat/user-tracking-state/${this.currentUser.id}` + ); + } + + resetTrackingStateForChannel(channelId) { + const trackingState = + this.currentUser.chat_channel_tracking_state[channelId]; + if (trackingState) { + trackingState.set("unread_count", 0); + this.userChatChannelTrackingStateChanged(); + } + } + + userChatChannelTrackingStateChanged() { + this._recalculateUnreadMessages(); + this.appEvents.trigger("chat:user-tracking-state-changed"); + } + + _recalculateUnreadMessages() { + let unreadPublicCount = 0; + let unreadUrgentCount = 0; + let headerNeedsRerender = false; + + Object.values(this.currentUser.chat_channel_tracking_state).forEach( + (state) => { + if (state.muted) { + return; + } + + if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) { + unreadUrgentCount += state.unread_count || 0; + } else { + unreadUrgentCount += state.unread_mentions || 0; + unreadPublicCount += state.unread_count || 0; + } + } + ); + + let hasUnreadPublic = unreadPublicCount > 0; + if (hasUnreadPublic !== this.hasUnreadMessages) { + headerNeedsRerender = true; + this.set("hasUnreadMessages", hasUnreadPublic); + } + + if (unreadUrgentCount !== this.unreadUrgentCount) { + headerNeedsRerender = true; + this.set("unreadUrgentCount", unreadUrgentCount); + } + + this.currentUser.notifyPropertyChange("chat_channel_tracking_state"); + if (headerNeedsRerender) { + this.appEvents.trigger("chat:rerender-header"); + this.appEvents.trigger("notifications:changed"); + } + } + + processChannel(channel) { + channel = ChatChannel.create(channel); + this._subscribeToSingleUpdateChannel(channel); + this._updateUserTrackingState(channel); + this.allChannels.push(channel); + return channel; + } + + _updateUserTrackingState(channel) { + this.currentUser.chat_channel_tracking_state[channel.id] = + EmberObject.create({ + chatable_type: channel.chatable_type, + muted: channel.current_user_membership.muted, + unread_count: channel.current_user_membership.unread_count, + unread_mentions: channel.current_user_membership.unread_mentions, + chat_message_id: channel.current_user_membership.last_read_message_id, + }); + } + + upsertDmChannelForUser(channel, user) { + const usernames = (channel.chatable.users || []) + .mapBy("username") + .concat(user.username) + .uniq(); + + return this.upsertDmChannelForUsernames(usernames); + } + + // @param {array} usernames - The usernames to create or fetch the direct message + // channel for. The current user will automatically be included in the channel + // when it is created. + upsertDmChannelForUsernames(usernames) { + return ajax("/chat/direct_messages/create.json", { + method: "POST", + data: { usernames: usernames.uniq() }, + }) + .then((response) => { + const chatChannel = ChatChannel.create(response.chat_channel); + this.startTrackingChannel(chatChannel); + return chatChannel; + }) + .catch(popupAjaxError); + } + + // @param {array} usernames - The usernames to fetch the direct message + // channel for. The current user will automatically be included as a + // participant to fetch the channel for. + getDmChannelForUsernames(usernames) { + return ajax("/chat/direct_messages.json", { + data: { usernames: usernames.uniq().join(",") }, + }); + } + + _saveDraft(channelId, draft) { + const data = { chat_channel_id: channelId }; + if (draft) { + data.data = JSON.stringify(draft); + } + + ajax("/chat/drafts", { type: "POST", data, ignoreUnsent: false }) + .then(() => { + this.markNetworkAsReliable(); + }) + .catch((error) => { + if (!error.jqXHR?.responseJSON?.errors?.length) { + this.markNetworkAsUnreliable(); + } + }); + } + + setDraftForChannel(channel, draft) { + if ( + draft && + (draft.value || draft.uploads.length > 0 || draft.replyToMsg) + ) { + this.draftStore[channel.id] = draft; + } else { + delete this.draftStore[channel.id]; + draft = null; // _saveDraft will destroy draft + } + + discourseDebounce(this, this._saveDraft, channel.id, draft, 2000); + } + + getDraftForChannel(channelId) { + return ( + this.draftStore[channelId] || { + value: "", + uploads: [], + replyToMsg: null, + } + ); + } + + updateLastReadMessage() { + discourseDebounce(this, this._queuedReadMessageUpdate, READ_INTERVAL); + } + + _queuedReadMessageUpdate() { + const visibleMessages = document.querySelectorAll( + ".chat-message-container[data-visible=true]" + ); + const channel = this.activeChannel; + + if ( + !channel?.isFollowing || + visibleMessages?.length === 0 || + !userPresent() + ) { + return; + } + + const latestUnreadMsgId = parseInt( + visibleMessages[visibleMessages.length - 1].dataset.id, + 10 + ); + + const hasUnreadMessages = latestUnreadMsgId > channel.lastSendReadMessageId; + + if ( + !hasUnreadMessages && + this.currentUser.chat_channel_tracking_state[this.activeChannel.id] + ?.unread_count > 0 + ) { + // Weird state here where the chat_channel_tracking_state is wrong. Need to reset it. + this.resetTrackingStateForChannel(this.activeChannel.id); + } + + if (hasUnreadMessages) { + channel.updateLastReadMessage(latestUnreadMsgId); + } + } + + addToolbarButton() { + deprecated( + "Use the new chat API `api.registerChatComposerButton` instead of `chat.addToolbarButton`" + ); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js b/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js new file mode 100644 index 00000000000..9e0c594eb8b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/emoji-picker-scroll-observer.js @@ -0,0 +1,70 @@ +import Service, { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; + +export default class EmojiPickerScrollObserver extends Service { + @service chatEmojiPickerManager; + + @tracked enabled = true; + direction = "up"; + prevYPosition = 0; + + @bind + _observerCallback(event) { + if (!this.enabled) { + return; + } + + this._setScrollDirection(event.target); + + const visibleSections = [ + ...document.querySelectorAll(".chat-emoji-picker__section"), + ].filter((sectionElement) => + this._isSectionVisibleInPicker(sectionElement, event.target) + ); + + if (visibleSections?.length) { + let sectionElement; + + if (this.direction === "up" || this.prevYPosition < 50) { + sectionElement = visibleSections.firstObject; + } else { + sectionElement = visibleSections.lastObject; + } + + this.chatEmojiPickerManager.lastVisibleSection = + sectionElement.dataset.section; + + this.chatEmojiPickerManager.addVisibleSections( + visibleSections.map((s) => s.dataset.section) + ); + } + } + + observe(element) { + element.addEventListener("scroll", this._observerCallback); + } + + unobserve(element) { + element.removeEventListener("scroll", this._observerCallback); + } + + _setScrollDirection(target) { + if (target.scrollTop > this.prevYPosition) { + this.direction = "down"; + } else { + this.direction = "up"; + } + + this.prevYPosition = target.scrollTop; + } + + _isSectionVisibleInPicker(section, picker) { + const { bottom, height, top } = section.getBoundingClientRect(); + const containerRect = picker.getBoundingClientRect(); + + return top <= containerRect.top + ? containerRect.top - top <= height + : bottom - containerRect.bottom <= height; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/full-page-chat.js b/plugins/chat/assets/javascripts/discourse/services/full-page-chat.js new file mode 100644 index 00000000000..f50630d5f2e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/full-page-chat.js @@ -0,0 +1,33 @@ +import KeyValueStore from "discourse/lib/key-value-store"; +import Service from "@ember/service"; + +const FULL_PAGE = "fullPage"; +const STORE_NAMESPACE_CHAT_WINDOW = "discourse_chat_window_"; + +export default class FullPageChat extends Service { + store = new KeyValueStore(STORE_NAMESPACE_CHAT_WINDOW); + _previousRouteInfo = null; + _isActive = false; + + enter(previousRouteInfo) { + this._previousRouteInfo = previousRouteInfo; + this._isActive = true; + } + + exit() { + this._isActive = false; + return this._previousRouteInfo; + } + + get isActive() { + return this._isActive; + } + + get isPreferred() { + return !!this.store.getObject(FULL_PAGE); + } + + set isPreferred(value) { + this.store.setObject({ key: FULL_PAGE, value }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs new file mode 100644 index 00000000000..728db2658e4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs @@ -0,0 +1,125 @@ +{{#if this.selectedWebhook}} + + +
+
+ + +
+ +
+ + + + {{i18n "chat.channel_edit_description_modal.description"}} + + + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs new file mode 100644 index 00000000000..1fb76bd8221 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs @@ -0,0 +1,15 @@ + + + + {{i18n "chat.channel_edit_title_modal.description"}} + + + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs new file mode 100644 index 00000000000..656fbf91f00 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-selector-modal.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs new file mode 100644 index 00000000000..e06c378ec88 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-toggle.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs new file mode 100644 index 00000000000..d35d8c4e240 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-message-move-to-channel-modal.hbs @@ -0,0 +1 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs new file mode 100644 index 00000000000..e39cc9a9d59 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs @@ -0,0 +1,33 @@ + + + + + {{#if this.categoryPermissionsHint}} +
+ {{this.categoryPermissionsHint}} +
+ {{/if}} + + {{#if this.autoJoinAvailable}} + + {{/if}} + + + + + + +
+ + diff --git a/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs new file mode 100644 index 00000000000..f8f46baa0ff --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/preferences/chat.hbs @@ -0,0 +1,47 @@ + + +
+ +
+ +
+ + + {{i18n "chat.only_chat_push_notifications.description"}} + +
+ +
+ + + {{i18n "chat.ignore_channel_wide_mention.description"}} + +
+ +
+ + +
+ +
+ + + {{#if (eq this.model.user_option.chat_email_frequency "when_away")}} +
+ {{i18n "chat.email_frequency.description"}} +
+ {{/if}} +
+ + diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js new file mode 100644 index 00000000000..8ae4e92467e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-header-icon.js @@ -0,0 +1,83 @@ +import getURL from "discourse-common/lib/get-url"; +import { createWidget } from "discourse/widgets/widget"; +import { h } from "virtual-dom"; +import { iconNode } from "discourse-common/lib/icon-library"; + +export default createWidget("header-chat-link", { + buildKey: () => "header-chat-link", + chat: null, + tagName: "li.header-dropdown-toggle.open-chat", + title: "chat.title", + services: ["chat", "router", "fullPageChat"], + + html() { + if (!this.chat.userCanChat) { + return; + } + + if (this.currentUser.isInDoNotDisturb()) { + return this.chatLinkHtml(); + } + + let indicator; + if (this.chat.unreadUrgentCount) { + indicator = h( + "div.chat-channel-unread-indicator.urgent", + {}, + h( + "div.number-wrap", + {}, + h("div.number", {}, this.chat.unreadUrgentCount) + ) + ); + } else if (this.chat.hasUnreadMessages) { + indicator = h("div.chat-channel-unread-indicator"); + } + + return this.chatLinkHtml(indicator); + }, + + chatLinkHtml(indicatorNode) { + return h( + `a.icon${ + this.fullPageChat.isActive || this.chat.chatOpen ? ".active" : "" + }`, + { attributes: { tabindex: 0 } }, + [iconNode("comment"), indicatorNode].filter(Boolean) + ); + }, + + mouseUp(e) { + if (e.which === 2) { + // Middle mouse click + window.open(getURL("/chat"), "_blank").focus(); + } + }, + + keyUp(e) { + if (e.code === "Enter") { + return this.click(); + } + }, + + click() { + if (this.fullPageChat.isActive && !this.site.mobileView) { + return; + } + + if ( + this.chat.sidebarActive || + this.site.mobileView || + this.fullPageChat.isPreferred + ) { + this.fullPageChat.isPreferred = true; + return this.router.transitionTo("chat"); + } else { + this.appEvents.trigger("chat:toggle-open"); + } + }, + + chatRerenderHeader() { + this.scheduleRerender(); + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js new file mode 100644 index 00000000000..31d71168fe4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-invitation-notification-item.js @@ -0,0 +1,42 @@ +import I18n from "I18n"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; +import { h } from "virtual-dom"; +import { formatUsername } from "discourse/lib/utilities"; +import { iconNode } from "discourse-common/lib/icon-library"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +createWidgetFrom(DefaultNotificationItem, "chat-invitation-notification-item", { + services: ["chat", "router"], + + text(data) { + const username = formatUsername(data.invited_by_username); + return I18n.t("notifications.chat_invitation_html", { username }); + }, + + html(attrs) { + const notificationType = attrs.notification_type; + const lookup = this.site.get("notificationLookup"); + const notificationName = lookup[notificationType]; + const { data } = attrs; + const text = this.text(data); + const title = this.notificationTitle(notificationName, data); + const html = new RawHtml({ html: `
${text}
` }); + const contents = [iconNode("link"), html]; + const href = this.url(data); + + return h( + "a", + { attributes: { title, href, "data-auto-route": true } }, + contents + ); + }, + + url(data) { + const title = data.chat_channel_title + ? slugifyChannel(data.chat_channel_title) + : "-"; + return `/chat/channel/${data.chat_channel_id}/${title}?messageId=${data.chat_message_id}`; + }, +}); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js new file mode 100644 index 00000000000..ea8c59cfde3 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/chat-mention-notification-item.js @@ -0,0 +1,63 @@ +import I18n from "I18n"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidgetFrom } from "discourse/widgets/widget"; +import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; +import { h } from "virtual-dom"; +import { formatUsername } from "discourse/lib/utilities"; +import { iconNode } from "discourse-common/lib/icon-library"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +const chatNotificationItem = { + services: ["chat", "router"], + + text(notificationName, data) { + const username = formatUsername(data.mentioned_by_username); + const identifier = data.identifier ? `@${data.identifier}` : null; + const i18nPrefix = data.is_direct_message_channel + ? "notifications.popup.direct_message_chat_mention" + : "notifications.popup.chat_mention"; + const i18nSuffix = identifier ? "other_html" : "direct_html"; + + return I18n.t(`${i18nPrefix}.${i18nSuffix}`, { + username, + identifier, + channel: data.chat_channel_title, + }); + }, + + html(attrs) { + const notificationType = attrs.notification_type; + const lookup = this.site.get("notificationLookup"); + const notificationName = lookup[notificationType]; + const { data } = attrs; + const title = this.notificationTitle(notificationName, data); + const text = this.text(notificationName, data); + const html = new RawHtml({ html: `
${text}
` }); + const contents = [iconNode("comment"), html]; + const href = this.url(data); + + return h( + "a", + { attributes: { title, href, "data-auto-route": true } }, + contents + ); + }, + + url(data) { + const title = data.chat_channel_title + ? slugifyChannel(data.chat_channel_title) + : "-"; + return `/chat/channel/${data.chat_channel_id}/${title}?messageId=${data.chat_message_id}`; + }, +}; + +createWidgetFrom( + DefaultNotificationItem, + "chat-mention-notification-item", + chatNotificationItem +); +createWidgetFrom( + DefaultNotificationItem, + "chat-group-mention-notification-item", + chatNotificationItem +); diff --git a/plugins/chat/assets/javascripts/discourse/widgets/topic-chat-button.js b/plugins/chat/assets/javascripts/discourse/widgets/topic-chat-button.js new file mode 100644 index 00000000000..9bb7f2674f4 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/widgets/topic-chat-button.js @@ -0,0 +1,22 @@ +import hbs from "discourse/widgets/hbs-compiler"; +import { createWidget } from "discourse/widgets/widget"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; + +export default createWidget("topic-chat-button", { + tagName: "button.btn.btn-default.topic-chat-button", + title: "chat.open", + + click() { + this.appEvents.trigger( + "chat:open-channel-for-chatable", + ChatChannel.create(this.attrs.chat_channel) + ); + }, + + template: hbs` + {{d-icon "far-comments"}} + + {{i18n "chat.topic_button_title"}} + + `, +}); diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js new file mode 100644 index 00000000000..1f1d00fa87e --- /dev/null +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js @@ -0,0 +1,255 @@ +import I18n from "I18n"; +import { performEmojiUnescape } from "pretty-text/emoji"; + +let customMarkdownCookFn; + +const chatTranscriptRule = { + tag: "chat", + + replace: function (state, tagInfo, content) { + // shouldn't really happen but we don't want to break rendering if it does + if (!customMarkdownCookFn) { + return; + } + + const options = state.md.options.discourse; + const [username, messageIdStart, messageTimeStart] = + (tagInfo.attrs.quote && tagInfo.attrs.quote.split(";")) || []; + const reactions = tagInfo.attrs.reactions; + const multiQuote = !!tagInfo.attrs.multiQuote; + const noLink = !!tagInfo.attrs.noLink; + const channelName = tagInfo.attrs.channel; + const channelId = tagInfo.attrs.channelId; + const channelLink = channelId + ? options.getURL(`/chat/channel/${channelId}/-`) + : null; + + if (!username || !messageIdStart || !messageTimeStart) { + return; + } + + let wrapperDivToken = state.push("div_chat_transcript_wrap", "div", 1); + let wrapperClasses = ["chat-transcript"]; + + if (!!tagInfo.attrs.chained) { + wrapperClasses.push("chat-transcript-chained"); + } + + wrapperDivToken.attrs = [["class", wrapperClasses.join(" ")]]; + wrapperDivToken.attrs.push(["data-message-id", messageIdStart]); + wrapperDivToken.attrs.push(["data-username", username]); + wrapperDivToken.attrs.push(["data-datetime", messageTimeStart]); + + if (reactions) { + wrapperDivToken.attrs.push(["data-reactions", reactions]); + } + + if (channelName) { + wrapperDivToken.attrs.push(["data-channel-name", channelName]); + + if (multiQuote) { + let metaDivToken = state.push("div_chat_transcript_meta", "div", 1); + metaDivToken.attrs = [["class", "chat-transcript-meta"]]; + const channelToken = state.push("html_inline", "", 0); + channelToken.content = I18n.t("chat.quote.original_channel", { + channel: channelName, + channelLink, + }); + state.push("div_chat_transcript_meta", "div", -1); + } + } + + if (channelId) { + wrapperDivToken.attrs.push(["data-channel-id", channelId]); + } + + let userDivToken = state.push("div_chat_transcript_user", "div", 1); + userDivToken.attrs = [["class", "chat-transcript-user"]]; + + // start: user avatar + let avatarDivToken = state.push( + "div_chat_transcript_user_avatar", + "div", + 1 + ); + avatarDivToken.attrs = [["class", "chat-transcript-user-avatar"]]; + + // server-side, we need to lookup the avatar from the username + let avatarImg; + if (options.lookupAvatar) { + avatarImg = options.lookupAvatar(username); + } + if (avatarImg) { + const avatarImgToken = state.push("html_inline", "", 0); + avatarImgToken.content = avatarImg; + } + + state.push("div_chat_transcript_user_avatar", "div", -1); + // end: user avatar + + // start: username + let usernameDivToken = state.push("div_chat_transcript_username", "div", 1); + usernameDivToken.attrs = [["class", "chat-transcript-username"]]; + + let displayName; + if (options.formatUsername) { + displayName = options.formatUsername(username); + } else { + displayName = username; + } + + const usernameToken = state.push("html_inline", "", 0); + usernameToken.content = displayName; + + state.push("div_chat_transcript_username", "div", -1); + // end: username + + // start: time + link to message + let datetimeDivToken = state.push("div_chat_transcript_datetime", "div", 1); + datetimeDivToken.attrs = [["class", "chat-transcript-datetime"]]; + + // for some cases, like archiving, we don't want the link to the + // chat message because it will just result in a 404 + // also handles the case where the quote doesn’t contain + // enough data to build a valid channel/message link + if (noLink || !channelLink) { + let spanToken = state.push("span_open", "span", 1); + spanToken.attrs = [["title", messageTimeStart]]; + + spanToken.block = false; + spanToken = state.push("span_close", "span", -1); + spanToken.block = false; + } else { + let linkToken = state.push("link_open", "a", 1); + linkToken.attrs = [ + ["href", `${channelLink}?messageId=${messageIdStart}`], + ["title", messageTimeStart], + ]; + + linkToken.block = false; + linkToken = state.push("link_close", "a", -1); + linkToken.block = false; + } + + state.push("div_chat_transcript_datetime", "div", -1); + // end: time + link to message + + // start: channel link for !multiQuote + if (channelName && !multiQuote) { + let channelLinkToken = state.push("link_open", "a", 1); + channelLinkToken.attrs = [ + ["class", "chat-transcript-channel"], + ["href", channelLink], + ]; + let inlineTextToken = state.push("html_inline", "", 0); + inlineTextToken.content = `#${channelName}`; + channelLinkToken = state.push("link_close", "a", -1); + channelLinkToken.block = false; + } + // end: channel link for !multiQuote + + state.push("div_chat_transcript_user", "div", -1); + + let messagesToken = state.push("div_chat_transcript_messages", "div", 1); + messagesToken.attrs = [["class", "chat-transcript-messages"]]; + + // rendering chat message content with limited markdown rule subset + const token = state.push("html_raw", "", 1); + token.content = customMarkdownCookFn(content); + state.push("html_raw", "", -1); + + if (reactions) { + let emojiHtmlCache = {}; + let reactionsToken = state.push( + "div_chat_transcript_reactions", + "div", + 1 + ); + reactionsToken.attrs = [["class", "chat-transcript-reactions"]]; + + reactions.split(";").forEach((reaction) => { + const split = reaction.split(":"); + const emoji = split[0]; + const usernames = split[1].split(","); + + const reactToken = state.push("div_chat_transcript_reaction", "div", 1); + reactToken.attrs = [["class", "chat-transcript-reaction"]]; + const emojiToken = state.push("html_inline", "", 0); + if (!emojiHtmlCache[emoji]) { + emojiHtmlCache[emoji] = performEmojiUnescape(`:${emoji}:`, { + getURL: options.getURL, + emojiSet: options.emojiSet, + emojiCDNUrl: options.emojiCDNUrl, + enableEmojiShortcuts: options.enableEmojiShortcuts, + inlineEmoji: options.inlineEmoji, + lazy: true, + }); + } + emojiToken.content = `${ + emojiHtmlCache[emoji] + } ${usernames.length.toString()}`; + state.push("div_chat_transcript_reaction", "div", -1); + }); + state.push("div_chat_transcript_reactions", "div", -1); + } + + state.push("div_chat_transcript_messages", "div", -1); + state.push("div_chat_transcript_wrap", "div", -1); + return true; + }, +}; + +export function setup(helper) { + helper.allowList([ + "div[class=chat-transcript]", + "div[class=chat-transcript chat-transcript-chained]", + "div.chat-transcript-meta", + "div.chat-transcript-user", + "div.chat-transcript-username", + "div.chat-transcript-user-avatar", + "div.chat-transcript-messages", + "div.chat-transcript-datetime", + "div.chat-transcript-reactions", + "div.chat-transcript-reaction", + "span[title]", + "div[data-message-id]", + "div[data-channel-name]", + "div[data-channel-id]", + "div[data-username]", + "div[data-datetime]", + "a.chat-transcript-channel", + ]); + + helper.registerOptions((opts, siteSettings) => { + opts.features["chat-transcript"] = !!siteSettings.chat_enabled; + }); + + helper.registerPlugin((md) => { + if (md.options.discourse.features["chat-transcript"]) { + md.block.bbcode.ruler.push("chat-transcript", chatTranscriptRule); + } + }); + + helper.buildCookFunction((opts, generateCookFunction) => { + if (!opts.discourse.additionalOptions) { + return; + } + + const chatAdditionalOpts = opts.discourse.additionalOptions.chat; + + // we need to be able to quote images from chat, but the image rule is usually + // banned for chat messages + const markdownItRules = + chatAdditionalOpts.limited_pretty_text_markdown_rules.concat("image"); + + generateCookFunction( + { + featuresOverride: chatAdditionalOpts.limited_pretty_text_features, + markdownItRules, + }, + (customCookFn) => { + customMarkdownCookFn = customCookFn; + } + ); + }); +} diff --git a/plugins/chat/assets/stylesheets/colors.scss b/plugins/chat/assets/stylesheets/colors.scss new file mode 100644 index 00000000000..e0e79e7d116 --- /dev/null +++ b/plugins/chat/assets/stylesheets/colors.scss @@ -0,0 +1,3 @@ +:root { + --chat-skeleton-animation-rgb: #{hexToRGB($primary-50)}; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-browse.scss b/plugins/chat/assets/stylesheets/common/chat-browse.scss new file mode 100644 index 00000000000..d2272227ed5 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-browse.scss @@ -0,0 +1,112 @@ +.chat-browse-view { + position: relative; + height: calc(100vh - var(--header-offset)); + overflow-y: scroll; + @include chat-scrollbar(var(--secondary)); + + @include breakpoint(mobile-large) { + padding-right: 1rem; //fix for different scroll behaviour on mobile where overflow-y:scroll acts like auto + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin: 1rem 0 1rem 1rem; + } + + &__title { + box-sizing: border-box; + } + + &__content_wrapper { + margin: 2rem 0 1rem 1rem; + box-sizing: border-box; + + @include breakpoint(tablet) { + margin-top: 1rem; + } + } + + &__cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 2.5rem; + + @include breakpoint(tablet) { + grid-template-columns: repeat(1, 1fr); + grid-gap: 1.5rem; + } + } + + &__actions { + display: flex; + justify-content: space-between; + align-items: end; + margin: 0 0 0 1rem; + + @include breakpoint(tablet) { + flex-direction: column; + + .dc-filter-input-container { + margin-top: 1rem; + } + + .dc-filter-input-container, + nav { + width: 100%; + } + } + } + + &__filters { + display: flex; + align-items: center; + margin: 0; + &:before { + content: none; //there is a strange thing applied on nav-pills and this resets it + } + + @include breakpoint(mobile-large) { + justify-content: space-between; + } + } + + &__filter { + display: inline; + margin-right: 1em; + + &:last-of-type { + margin-right: 0; + } + + @include breakpoint(mobile-large) { + margin: 0; + } + } + + &__filter-link, + &__filter-link:visited { + color: var(--primary); + font-size: var(--font-up-2); + padding: 0 0.25rem; + + @include breakpoint(tablet) { + font-size: var(--font-up-1); + } + } + + .chat-channel-card { + .chat-channel-card__leave-btn { + padding: 0; + &:hover, + &:focus { + background: none; + } + + &:focus { + @include default-focus; + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss new file mode 100644 index 00000000000..d8b47c37606 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-card.scss @@ -0,0 +1,116 @@ +.chat-channel-card { + display: flex; + flex-direction: column; + position: relative; + padding: 1.25rem; + background-color: var(--primary-very-low); + border-radius: 5px; + min-height: 0; + min-width: 0; + border-left: 5px solid transparent; + + &__header { + align-items: center; + display: flex; + } + + &__header-actions { + align-items: center; + display: flex; + margin-left: auto; + } + + &__read-restricted { + color: var(--primary-medium); + font-size: var(--font-down-4); + padding: 0 0.25rem; + } + + &__description { + @include line-clamp(2); + color: var(--primary-medium); + padding-top: 1rem; + + .-closed &, + .-archived & { + opacity: 0.5; + } + } + + &__setting { + svg { + fill: var(--primary-medium); + } + + .-archived & { + opacity: 0.5; + } + } + + &__members { + margin-left: auto; + font-size: 0.875rem; + } + + &__name { + @include ellipsis; + } + + &__name-container { + display: flex; + align-items: center; + color: var(--primary); + font-size: 1.15rem; + text-decoration: none; + min-width: 0; + margin-right: 2rem; + + &:visited, + &:hover { + color: var(--primary); + } + + .-closed &, + .-archived & { + opacity: 0.5; + } + } + + &__tag { + border-radius: 10px; + margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + text-transform: uppercase; + font-size: 0.7rem; + font-weight: bold; + background-color: var(--secondary); + + &.-muted { + color: var(--primary-medium); + border: 1px solid var(--primary-low-mid); + + & + .chat-channel-card__setting { + margin-left: 0.5rem; + } + } + &.-joined { + color: var(--success); + border: 1px solid var(--success); + } + + &.-closed, + &.-archived { + display: inline-block; + padding-left: 0; + margin-bottom: 0.5rem; + } + } + + &__cta { + flex-grow: 1; + display: flex; + justify-content: space-between; + align-items: end; + margin-top: 1rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss new file mode 100644 index 00000000000..e6c448ab4ab --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss @@ -0,0 +1,157 @@ +.channel-info { + display: flex; + flex-direction: column; + height: 100%; + + &__back-btn { + font-size: var(--font-down-1); + margin: 0 0 1rem 0; + } +} + +// Info header +.channel-info-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + box-sizing: border-box; +} + +.channel-info__back-btn { + margin-bottom: 1rem; + display: block; +} + +.channel-info-header__title { + font-size: var(--font-up-2); + margin: 0; +} + +// About view +.channel-info-about-view__title-input { + width: 100%; +} + +.channel-info-about-view__description-input { + height: 150px; + width: 100%; +} + +.channel-info-about-view__description__helper-text { + color: var(--primary-medium); +} + +// Settings view +.channel-settings-view__saved { + color: var(--success); + padding-left: 0.5rem; +} + +.channel-settings-view__desktop-notification-level-selector, +.channel-settings-view__mobile-notification-level-selector, +.channel-settings-view__muted-selector { + width: 220px; +} + +.chat-form__btn.delete-btn { + .d-icon { + color: var(--danger); + } +} + +// Members list +.chat-tabs__memberships-count { + margin-left: 0.25em; +} + +.channel-members-view-wrapper { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + padding: 0 1rem; +} + +.channel-members-view__search-input-container { + display: flex; + align-items: center; + border: 1px solid var(--primary-medium); + + &.is-focused { + border: 1px solid var(--tertiary); + } + + .d-icon { + padding: 0.5rem; + color: var(--primary-medium); + } +} + +input.channel-members-view__search-input { + border: 0; + margin: 0; + outline: 0; + width: 100%; + + &:focus { + border: 0; + outline: 0; + } +} + +.channel-members-view__status { + display: flex; + align-items: center; +} + +.channel-members-view__list-container { + display: flex; + flex-direction: column; + margin-top: 1em; + box-sizing: border-box; + min-height: 1px; + overflow-y: auto; + height: 100%; + @include chat-scrollbar(var(--secondary)); +} + +.channel-members-view__list-item { + display: flex; + align-items: center; + padding: 0.5rem 0 0.5rem 1px; + + &:hover { + background-color: var(--tertiary-very-low); + border-radius: 0.25rem; + } + + .chat-user-avatar { + margin-right: 0.5rem; + } +} + +// Channel info edit title modal +.chat-channel-edit-title-modal__title-input { + display: flex; + margin: 0; +} + +.chat-channel-edit-title-modal__description { + display: flex; + padding: 0.5rem 0; + color: var(--primary-medium); +} + +// Channel info edit description modal +.chat-channel-edit-description-modal__description-input { + display: flex; + margin: 0; + min-height: 200px; +} + +.chat-channel-edit-description-modal__description { + display: flex; + padding: 0.75rem 0 0.5rem; + color: var(--primary-medium); +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss new file mode 100644 index 00000000000..175f7402e28 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-preview-card.scss @@ -0,0 +1,39 @@ +.chat-channel-preview-card { + margin: 1rem 1rem 2rem 1rem; + padding: 1.5rem 1rem; + background-color: var(--primary-50); + display: flex; + flex-direction: column; + align-items: center; + + &.-no-description { + .chat-channel-title { + margin-bottom: 1.5rem; + } + } + + &__description { + color: var(--primary-600); + text-align: center; + } + + .chat-channel-title__name { + font-size: var(--font-up-2); + } + + &__join-channel-btn { + font-size: var(--font-up-2); + border: 1px solid transparent; + border-radius: 0.25rem; + line-height: normal; + box-sizing: border-box; + padding: 0.5em 0.65em; + font-weight: normal; + cursor: pointer; + } + + &__browse-all { + margin-top: 1rem; + font-size: var(--font-down-1); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss new file mode 100644 index 00000000000..c5935c249aa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-selector-modal.scss @@ -0,0 +1,63 @@ +:root { + --chat-channel-selector-input-height: 40px; +} + +.chat-channel-selector-modal-modal.modal.in { + animation: none; +} + +#chat-channel-selector-modal-inner { + width: 500px; + height: 350px; + + .chat-channel-selector-input-container { + position: relative; + + .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--primary-high); + } + + #chat-channel-selector-input { + width: 100%; + height: var(--chat-channel-selector-input-height); + padding-left: 30px; + margin: 0 0 1px; + } + } + .channels { + height: calc(100% - var(--chat-channel-selector-input-height)); + overflow: auto; + + .no-channels-notice { + padding: 0.5em; + } + + .chat-channel-selection-row { + display: flex; + align-items: center; + height: 2.5em; + padding-left: 0.5em; + + &.focused { + background: var(--primary-low); + } + .username { + margin-left: 0.5em; + } + .chat-channel-title { + color: var(--primary-high); + } + + .chat-channel-unread-indicator { + border: none; + margin-left: 0.5em; + height: 12px; + width: 12px; + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-title.scss b/plugins/chat/assets/stylesheets/common/chat-channel-title.scss new file mode 100644 index 00000000000..c54f7f39232 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel-title.scss @@ -0,0 +1,123 @@ +.chat-channel-title { + display: flex; + align-items: center; + + .category-chat-private .d-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 5px; + padding: 2px 2px 3px; + color: var(--primary-high); + height: 0.5em; + width: 0.5em; + left: calc(0.6125em + 3px); + top: -4px; + } + + .chat-name, + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + &__usernames, + .dm-usernames { + @include ellipsis; + font-size: var(--font-0); + margin: 0; + + .emoji { + height: 1.2em; + vertical-align: text-bottom; + width: 1.2em; + } + } + + .d-icon-lock { + margin-right: 0.25em; + } + + .topic-chat-icon { + color: var(--primary-medium); + display: flex; + } + + .chat-unread-count { + display: inline-block; + color: var(--secondary); + background-color: var(--tertiary-med-or-tertiary); + font-size: var(--font-down-2); + border-radius: 100%; + min-width: 1.4em; + min-height: 1.4em; + height: 1.4em; + width: 1.4em; + padding: 1px; + margin-left: 0.5rem; + text-align: center; + } +} + +.chat-channel-title__users-count { + display: flex; + border-radius: 3px; + background: rgba(var(--primary-rgb), 0.1); + text-align: center; + font-weight: 700; + font-size: var(--font-down-1); + align-items: center; + padding: 0.25rem 0.5rem; + + & + .chat-channel-title__name { + margin-left: 0.5rem; + } +} + +.chat-channel-title__category-badge { + color: var(--primary-medium); + display: flex; + font-size: var(--font-up-1); + position: relative; + + + .chat-channel-title__name { + margin-left: 0.5rem; + } +} + +.chat-channel-title .chat-user-avatar { + font-size: var(--font-up-1); + + + .chat-channel-title__usernames { + margin-left: 0.5rem; + } +} + +.chat-channel-title__restricted-category-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 50%; + padding: 2px 2px 3px; + color: var(--primary-high); + height: 0.5rem; + width: 0.5rem; + right: -0.5rem; + top: -0.1rem; +} + +.chat-channel-title__category-title { + .emoji { + height: 1.2em; + vertical-align: text-bottom; + width: 1.2em; + } +} + +.chat-channel-title__name { + @include ellipsis; + font-size: var(--font-0); + color: var(--primary); +} + +.channel-info { + .chat-channel-title__name { + max-width: 100%; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss new file mode 100644 index 00000000000..870202a1e2f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss @@ -0,0 +1,55 @@ +.chat-composer-dropdown { + margin-left: 0.2rem; + + .tippy-content { + padding: 0; + } +} + +.chat-composer-dropdown__trigger-btn { + padding: 5px; + border-radius: 100%; + background: var(--primary-med-or-secondary-high); + border: 1px solid transparent; + display: flex; + + .d-icon { + color: var(--secondary-very-high); + } + + &:focus { + border-color: var(--tertiary); + } + + .discourse-no-touch &:hover { + background: var(--primary-high); + .d-icon { + color: var(--primary-low); + } + } +} + +.chat-composer-dropdown__list { + padding: 0; + margin: 0; + list-style: none; + padding: 0.5rem; +} + +.chat-composer-dropdown__item { + padding-bottom: 0.25rem; + + &:last-child { + padding-bottom: 0; + } +} + +.chat-composer-dropdown__action-btn { + background: none; + width: 100%; + justify-content: flex-start; + + .d-icon { + color: var(--primary); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss b/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss new file mode 100644 index 00000000000..e1b019a3154 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-inline-button.scss @@ -0,0 +1,9 @@ +.chat-composer-inline-button { + border-radius: 6px; + width: 32px; + height: 32px; + + & + .chat-composer-inline-button { + margin-left: 0.25rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss new file mode 100644 index 00000000000..5d4b36303d2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss @@ -0,0 +1,71 @@ +.chat-composer-upload { + display: inline-flex; + height: 50px; + padding: 0.5rem; + border: 1px solid var(--primary-low-mid); + margin-right: 0.5em; + + &:last-child { + margin-right: 0; + } + + .preview { + width: 50px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 1em 0 0; + border-radius: 8px; + + .d-icon { + font-size: var(--font-up-6); + } + + .preview-img { + max-width: 100%; + max-height: 100%; + } + } + + .data { + display: flex; + flex-direction: column; + justify-content: center; + line-height: var(--line-height-medium); + font-size: var(--font-down-1); + color: var(--primary-high); + + .top-data, + .bottom-data { + display: flex; + align-items: center; + } + + .file-name { + display: inline-block; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 0.5em; + } + + .uploading, + .processing { + font-size: var(--font-down-2); + margin-right: 0.75em; + } + + .upload-progress { + width: 110px; + } + + .extension-pill { + background: var(--primary-low); + border-radius: 5px; + font-size: var(--font-down-2-rem); + padding: 0.1em 0.4em; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss new file mode 100644 index 00000000000..8050774913f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss @@ -0,0 +1,8 @@ +.chat-composer-uploads { + .chat-composer-uploads-container { + padding: 0.5rem 10px; + display: flex; + white-space: nowrap; + overflow-x: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss new file mode 100644 index 00000000000..9ae341f2473 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -0,0 +1,140 @@ +.chat-composer-container { + display: flex; + flex-direction: column; + + #chat-full-page-uploader, + #chat-widget-uploader { + display: none; + } + + .drop-a-file { + display: none; + } +} + +.chat-composer { + display: flex; + align-items: center; + background-color: var(--secondary); + border: 1px solid var(--primary-low-mid); + border-radius: 5px; + padding: 0.15rem 0.25rem; + margin-top: 0.5rem; + + &.is-disabled { + background-color: var(--primary-low); + border: 1px solid var(--primary-low-mid); + } + + .send-btn { + padding: 0.4rem 0.5rem; + border: 1px solid transparent; + border-radius: 5px; + display: flex; + align-items: center; + + .d-icon { + color: var(--tertiary); + } + + &:disabled { + cursor: not-allowed; + + .d-icon { + color: var(--primary-low); + } + } + + &:not(:disabled) { + &:hover, + &:focus { + background: var(--tertiary); + .d-icon { + color: var(--secondary); + } + } + } + } + + &__close-emoji-picker-btn { + margin-left: 0.2rem; + padding: 5px !important; + border-radius: 100%; + background: var(--primary-med-or-secondary-high); + border: 1px solid transparent; + display: flex; + + .d-icon { + color: var(--secondary-very-high); + } + + &:focus { + border-color: var(--tertiary); + } + + .discourse-no-touch &:hover { + background: var(--primary-high); + .d-icon { + color: var(--primary-low); + } + } + } + + .chat-composer-input { + overflow-x: hidden; + width: 100%; + appearance: none; + outline: none; + border: 0; + resize: none; + max-height: 125px; + scrollbar-color: var(--primary-low-mid) transparent; + transition: scrollbar-color 0.2s ease-in-out; + background: none; + margin: 0; + padding: 0.25rem 0.5rem; + text-overflow: ellipsis; + + &:placeholder-shown, + &::placeholder { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &::-webkit-scrollbar-thumb { + background-color: var(--primary-low-mid); + border-radius: 6px; + border: 3px solid var(--secondary); + } + &:hover { + scrollbar-color: var(--primary-low-mid) transparent; + &::-webkit-scrollbar-thumb { + background-color: var(--primary-low-mid); + } + } + &::-webkit-scrollbar { + width: 12px; + } + } + + &__unreliable-network { + color: var(--danger); + padding: 0 0.5em; + } +} + +.chat-composer-message-details { + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--primary-low); + display: flex; + align-items: center; + @include ellipsis; + position: relative; + height: 100%; + max-height: calc(2em - 5px); + + .cancel-message-action { + margin-left: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss new file mode 100644 index 00000000000..6f295468c6e --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss @@ -0,0 +1,43 @@ +.full-page-chat.teams-sidebar-on { + .chat-draft { + grid-template-columns: 1fr; + } +} + +.chat-draft { + height: 100%; + min-height: 1px; + width: 100%; + display: flex; + flex-direction: column; + flex: 1; + + &-header { + display: flex; + align-items: center; + padding: 0.75em 10px; + border-bottom: 1px solid var(--primary-low); + + &__title { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0; + margin-left: 0.5rem; + font-size: var(--font-0); + font-weight: normal; + color: var(--primary); + @include ellipsis; + + .d-icon { + height: 1.5em; + width: 1.5em; + color: var(--quaternary); + } + } + } + + .chat-composer-container { + padding-bottom: 0.5em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-drawer.scss b/plugins/chat/assets/stylesheets/common/chat-drawer.scss new file mode 100644 index 00000000000..06421a7026a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss @@ -0,0 +1,210 @@ +body.composer-open .topic-chat-float-container { + bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator +} + +.topic-chat-float-container { + font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + // higher than timeline, lower than composer, lower than user card (bump up below) + z-index: z("usercard"); + position: fixed; + right: var(--composer-right, 20px); + left: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + pointer-events: none !important; + bottom: 0; + + > * { + pointer-events: auto; + } + + .no-channel-title { + font-weight: bold; + margin-left: 0.5rem; + } + + &.composer-draft-collapsed { + bottom: 40px; + } + + box-sizing: border-box; + max-height: 90vh; + padding-bottom: var(--composer-height, 0); + transition: all 100ms ease-in; + transition-property: bottom, padding-bottom; + + .channels-list { + .chat-channel-row { + padding: 0 0 0 0.5rem; + margin: 0 0.5rem 0.125rem 0.5rem; + border-radius: 0.25em; + } + + .chat-channel-unread-indicator { + left: 3px; + min-width: 8px; + width: 8px; + height: 8px; + border-radius: 7px; + top: calc(50% - 5px); + } + + .chat-channel-title { + padding: 0.5rem; + } + } +} + +.chat-drawer { + align-self: flex-end; +} + +.topic-chat-container { + background: var(--secondary); + border: 1px solid var(--primary-low); + border-bottom: 0; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.125); + box-sizing: border-box; + display: flex; + flex-direction: column; + + &.expanded { + max-height: $float-height; + height: calc(85vh - var(--composer-height, 0px)); + } + + .chat-live-pane { + height: 100%; + } +} + +.topic-chat-drawer-header__left-actions { + display: flex; + height: 100%; +} + +.topic-chat-drawer-header__right-actions { + display: flex; + height: 100%; +} + +.topic-chat-drawer-header__top-line { + height: 2.5rem; + display: flex; + align-items: center; +} + +.topic-chat-drawer-header__bottom-line { + height: 1.5rem; + display: flex; + align-items: start; +} + +.topic-chat-drawer-header__title { + @include ellipsis; + display: flex; + flex-direction: column; + width: 100%; + font-weight: 700; + padding: 0 0.5rem; + cursor: pointer; + + .chat-channel-title { + padding: 0; + } +} + +.topic-chat-drawer-header { + box-sizing: border-box; + border-bottom: solid 1px var(--primary-low); + border-radius: 8px 8px 0 0; + background: var(--primary-very-low); + width: 100%; + display: flex; + align-items: flex-start; + + .btn { + height: 100%; + } + + .chat-channel-title { + font-weight: 700; + width: 100%; + + .chat-name, + .topic-chat-name, + .category-chat-name, + .dm-usernames { + color: var(--primary); + } + .category-chat-badge, + .topic-chat-badge { + display: flex; + justify-content: center; + align-content: center; + .d-icon:not(.d-icon-lock) { + width: 1.25em; + height: 1.25em; + } + } + .category-chat-private .d-icon { + background-color: var(--primary-very-low); + } + .badge-wrapper.bullet { + margin-right: 0px; + } + .dm-usernames { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .d-icon:not(.d-icon-hashtag) { + color: var(--primary-high); + } + .category-hashtag { + padding: 2px 4px; + } + } + + &__return-to-channels-btn, + &__close-btn, + &__full-screen-btn, + &__expand-btn { + height: 100%; + max-height: 2.5rem; + width: 2.5rem; + + &:focus { + outline: none; + background: none; + + .d-icon { + background: none; + color: var(--primary-low-mid); + } + } + + &:hover { + .d-icon { + color: var(--primary-high); + } + } + } +} + +.topic-chat-drawer-content { + box-sizing: border-box; + height: 100%; + min-height: 1px; + padding-bottom: 0.25em; + + .channels-list .chat-channel-divider { + padding: 0.25rem 0.5rem 0.25rem 1rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss new file mode 100644 index 00000000000..bc61941f3a4 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss @@ -0,0 +1,234 @@ +.chat-emoji-picker { + border-top: 1px solid var(--primary-low); + transition: height 125ms ease; + display: flex; + flex-direction: column; + height: 300px; + overflow: hidden; + background: var(--secondary); + + .emoji { + padding: 6px; + width: 32px; + height: 32px; + image-rendering: -webkit-optimize-contrast; + cursor: pointer; + + &:hover, + &:focus { + background: var(--primary-very-low); + border-radius: 5px; + transform: scale(1.25); + } + } + + &__filter-container { + top: 0; + position: sticky; + background: var(--secondary); + display: flex; + height: 50px; + } + + &__filter { + width: 100%; + padding: 0.5rem; + margin: 0.25rem; + + input { + background: none; + width: 100%; + } + + .d-icon { + color: var(--primary-medium); + } + + &.dc-filter-input-container { + border-color: transparent; + background: var(--primary-very-low); + } + } + + &__scrollable-content { + height: 100%; + overflow-y: scroll; + text-transform: capitalize; + } + + &__no-reults { + padding: 1em; + } + + &__sections-nav { + top: 0; + position: sticky; + background: var(--secondary); + border-bottom: 1px solid var(--primary-low); + height: 50px; + display: flex; + align-items: center; + + &__indicator { + background: var(--tertiary); + height: 4px; + transition: transform 0.3s cubic-bezier(0.1, 0.82, 0.25, 1); + position: absolute; + bottom: 0; + } + } + + &__section-btn { + padding: 0.25rem; + + &:hover { + .emoji { + background: none; + } + } + + &:focus, + &.active { + background: none; + } + + .emoji { + width: 21px; + height: 21px; + } + } + + &__section-emojis { + padding: 0.5rem; + } + + &__backdrop { + height: 100%; + background: rgba(0, 0, 0, 0.75); + bottom: 0; + top: 0; + left: 0; + right: 0; + } + + &__section-title { + margin: 0; + padding: 0.5rem; + color: var(--primary-very-high); + font-size: var(--font-up-0); + font-weight: 700; + background: rgba(var(--secondary-rgb), 0.95); + position: sticky; + top: 0; + z-index: 1; + width: 100%; + box-sizing: border-box; + } + + &__fitzpatrick-modifier-btn { + min-width: 21px; + width: 21px; + height: 21px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + margin-right: 0.5rem; + border: 0; + border-radius: 5px; + + .d-icon { + visibility: hidden; + } + + &.current { + min-width: 25px; + width: 25px; + height: 25px; + } + + &:not(.current):hover, + &:not(.current):focus { + .d-icon { + visibility: visible; + color: white; + filter: drop-shadow(0.5px 1.5px 0 rgba(0, 0, 0, 0.3)); + } + } + + &:last-child { + margin-right: 0; + } + + &.t1 { + background: #ffcc4d; + } + &.t2 { + background: #f7dece; + } + &.t3 { + background: #f3d2a2; + } + &.t4 { + background: #d5ab88; + } + &.t5 { + background: #af7e57; + } + &.t6 { + background: #7c533e; + } + + @media (forced-colors: active) { + forced-color-adjust: none; + } + } + + &__fitzpatrick-scale { + display: flex; + align-items: center; + } +} + +.chat-message-emoji-picker-anchor { + z-index: z("header") + 1; + + .chat-emoji-picker { + border: 1px solid var(--primary-low); + width: 320px; + + .emoji { + width: 22px; + height: 22px; + } + } +} + +.mobile-view { + .chat-message-emoji-picker-anchor.-opened { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-shadow: shadowcreatePopper("card"); + + .chat-emoji-picker { + height: 50vh; + width: 100%; + } + } +} + +.chat-composer-container.with-emoji-picker { + background: var(--primary-very-low); + + .chat-emoji-picker { + border-bottom: 1px solid var(--primary-low); + + &.closing { + height: 0; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-form.scss b/plugins/chat/assets/stylesheets/common/chat-form.scss new file mode 100644 index 00000000000..2e3dd0ba5a2 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-form.scss @@ -0,0 +1,43 @@ +.chat-form__section { + padding: 1.5rem 1rem; + border-bottom: 1px solid var(--primary-low); + + &:first-child { + padding-top: 0; + } + + &:last-child { + margin-bottom: 0; + border-bottom: none; + } +} + +.chat-form__field { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.chat-form__btn { + border: 0; + background: none; + padding: 0.25rem 0; + margin: 0; +} + +.chat-form__label { + font-weight: 700; + display: flex; + align-items: center; +} + +.chat-form__label-actions { + margin-left: auto; + + .btn-text { + color: var(--tertiary); + font-size: var(--font-down-1); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss new file mode 100644 index 00000000000..c18d6aa83a9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-collapser.scss @@ -0,0 +1,46 @@ +$max_video_height: 150px; + +.chat-message-collapser { + .chat-message-collapser-header { + display: flex; + align-items: center; + } + + .chat-message-collapser-header + div p { + margin: 0; + } + + .chat-img-upload, + .chat-other-upload, + .chat-video-upload, + .chat-message-collapser-header + div p img { + margin-top: 0.25em; + margin-bottom: 0.5em; + } + + .chat-video-upload { + height: $max_video_height; + width: calc(#{$max_video_height} / 9 * 16); + } + + .chat-message-collapser-link-small { + font-size: 0.75em; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .chat-message-collapser-button { + background: none; + padding: unset; + margin-left: 0.5em; + + &:hover { + background: none; + + .d-icon { + color: var(--primary); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-images.scss b/plugins/chat/assets/stylesheets/common/chat-message-images.scss new file mode 100644 index 00000000000..2805ed5cacc --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-images.scss @@ -0,0 +1,27 @@ +$max_image_height: 150px; + +.chat-message { + // append selectors to set images to a + // max height of $max_image_height + .chat-message-collapser .onebox img:not(.ytp-thumbnail-image), + .chat-message-collapser img.onebox, + .chat-message-collapser .chat-uploads img, + .chat-message-collapser p img, + aside.onebox .onebox-body .aspect-image-full-size, + aside.onebox .onebox-body .aspect-image-full-size img { + object-fit: contain; + max-height: $max_image_height; + max-width: 100%; + width: unset; + overflow: hidden; + } + + .chat-message-collapser + .chat-message-collapser-header + + div + .chat-message-collapser-youtube { + object-fit: contain; + height: $max_image_height; + width: calc(#{$max_image_height} / 9 * 16); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-info.scss b/plugins/chat/assets/stylesheets/common/chat-message-info.scss new file mode 100644 index 00000000000..fb82db5baac --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-info.scss @@ -0,0 +1,74 @@ +.chat-message-info { + display: flex; + align-items: center; + justify-content: flex-start; +} + +.chat-message-info__username { + display: inline-flex; + align-items: center; + + & + .chat-message-info__bot-indicator, + & + .chat-message-info__date { + margin-left: 0.25em; + } +} + +.chat-message-info__username__name { + color: var(--secondary-low); + font-weight: 700; + @include ellipsis; + max-width: 180px; +} + +.chat-message-info__bot-indicator { + text-transform: uppercase; + padding: 0.25em; + background: var(--primary-low); + border-radius: 3px; + font-size: var(--font-down-2); + + & + .chat-message-info__date { + margin-left: 0.25em; + } +} + +.chat-message-info__date { + color: var(--primary-high); + font-size: var(--font-down-1); + + &:hover, + &:focus { + .chat-time { + color: var(--primary); + } + } + + & + .chat-message-info__flag { + margin-left: 0.25em; + } +} + +.chat-message-info__flag { + color: var(--secondary-medium); +} + +.chat-message-info__bookmark { + .d-icon-discourse-bookmark-clock, + .d-icon-bookmark { + color: var(--primary-low-mid); + font-size: var(--font-down-2); + margin-left: 0.5em; + } +} + +.chat-message-info__status { + display: flex; + margin-left: 0.2em; + margin-right: 0.2em; + + .emoji { + width: 16px; + height: 16px; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss new file mode 100644 index 00000000000..955082fe5fb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss @@ -0,0 +1,33 @@ +.chat-message-left-gutter { + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + width: var(--message-left-width); +} + +.chat-message-left-gutter__date { + color: var(--primary-high); + font-size: var(--font-down-1); + + &:hover, + &:focus { + .chat-time { + color: var(--primary); + } + } +} + +.chat-message-left-gutter__flag { + color: var(--secondary-medium); + padding-left: calc(50% - 15px); +} + +.chat-message-left-gutter__bookmark { + .d-icon-discourse-bookmark-clock, + .d-icon-bookmark { + color: var(--primary-low-mid); + font-size: var(--font-down-2); + margin-left: 0.5em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-separator.scss b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss new file mode 100644 index 00000000000..e918d0c850a --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-separator.scss @@ -0,0 +1,42 @@ +.chat-message-separator { + @include unselectable; + margin: 0.25rem 0 0.25rem 1rem; + display: flex; + font-size: var(--font-down-1); + position: relative; + transform: translateZ(0); + position: relative; + + &.new-message { + color: var(--danger-medium); + + .divider { + background-color: var(--danger-medium); + } + } + + &.first-daily-message { + .text { + color: var(--secondary-low); + font-weight: 600; + } + + .divider { + background-color: var(--secondary-high); + } + } + + .text { + margin: 0 auto; + padding: 0 0.75rem; + z-index: 1; + background: var(--secondary); + } + + .divider { + position: absolute; + width: 100%; + height: 1px; + top: 50%; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss new file mode 100644 index 00000000000..b5b013e60bf --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -0,0 +1,576 @@ +.chat-message-deleted, +.chat-message-hidden { + margin-left: calc(var(--message-left-width) + 0.75em); + padding: 0; + + .chat-message-expand { + color: var(--primary-low-mid); + padding: 0.25em; + + &:hover { + background: inherit; + color: inherit; + } + } +} + +@mixin chat-reaction { + align-items: center; + display: inline-flex; + padding: 0.3em 0.6em; + margin: 1px 0.25em 1px 0; + font-size: var(--font-down-2); + border-radius: 4px; + border: 1px solid var(--primary-low); + background: transparent; + cursor: pointer; + user-select: none; + transition: background 0.2s, border-color 0.2s; + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + + &:not(.reacted) { + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + } + } + + .emoji { + height: 15px; + margin-right: 4px; + width: auto; + } +} + +.chat-msgactions { + .chat-message-reaction { + @include chat-reaction; + + &:not(.show) { + display: none; + } + } +} + +.chat-message { + align-items: flex-start; + padding: 0.25em 0.5em 0.25em 0.75em; + background-color: var(--secondary); + display: flex; + min-width: 0; + + .chat-message-reaction { + @include chat-reaction; + + &:not(.show) { + display: none; + } + } + + &.chat-action { + background-color: var(--highlight-medium); + } + + &.errored { + color: var(--primary-medium); + } + + &.deleted { + background-color: var(--danger-low); + } + + .not-mobile-device &.deleted:hover { + background-color: var(--danger-hover); + } + + &.transition-slow { + transition: 2s linear background-color; + } + + &.user-info-hidden { + .chat-time { + color: var(--secondary-medium); + flex-shrink: 0; + font-size: var(--font-down-2); + margin-top: 0.4em; + display: none; + width: var(--message-left-width); + } + } + + &.is-reply { + display: grid; + grid-template-columns: var(--message-left-width) 1fr; + grid-template-rows: 30px auto; + grid-template-areas: + "replyto replyto" + "avatar message"; + + .chat-user-avatar { + grid-area: avatar; + } + + .chat-message-content { + grid-area: message; + } + } + + .chat-message-content { + display: flex; + flex-direction: column; + flex-grow: 1; + word-break: break-word; + overflow-wrap: break-word; + min-width: 0; + } + + .chat-message-text { + min-width: 0; + width: 100%; + + code { + box-sizing: border-box; + font-size: var(--font-down-1); + width: 100%; + } + + .mention.highlighted { + background: var(--tertiary-low); + color: var(--primary); + } + + .valid-mention { + padding: 0 4px 1px; + border-radius: 8px; + display: inline-block; + } + + img.ytp-thumbnail-image { + height: 100%; + max-height: unset; + + &:hover { + border-radius: 0; + } + } + + // Automatic aspect-ratio mapping https://developer.mozilla.org/en-US/docs/Web/Media/images/aspect_ratio_mapping + p img:not(.emoji) { + max-width: 100%; + height: auto; + } + + ul, + ol { + padding-left: 1.25em; + } + } + + .chat-message-edited { + display: inline-block; + color: var(--primary-medium); + font-size: var(--font-down-2); + } + + .chat-message-reaction-list, + .chat-transcript-reactions { + @include unselectable; + margin-top: 0.25em; + display: flex; + flex-wrap: wrap; + + .reaction-users-list { + position: absolute; + top: -2px; + transform: translateY(-100%); + border: 1px solid var(--primary-low); + border-radius: 6px; + padding: 0.5em; + background: var(--primary-very-low); + max-width: 300px; + z-index: 3; + } + + .chat-message-react-btn { + vertical-align: top; + padding: 0em 0.25em; + background: none; + border: none; + + .d-icon { + color: var(--primary-high); + } + + &:hover { + .d-icon { + color: var(--primary); + } + } + } + } + + .chat-send-error { + color: var(--danger-medium); + } + + .chat-message-mention-warning { + position: relative; + margin-top: 0.25em; + font-size: var(--font-down-1); + + .dismiss-mention-warning { + position: absolute; + top: 5px; + right: 5px; + cursor: pointer; + } + + .cannot-see, + .without-membership { + margin: 0.25em 0; + } + + .invite-link { + color: var(--tertiary); + cursor: pointer; + } + } + + .chat-message-avatar .chat-user-avatar .chat-user-avatar-container .avatar, + .chat-emoji-avatar .chat-emoji-avatar-container { + width: 28px; + height: 28px; + } +} + +.chat-message-container.highlighted .chat-message { + background-color: var(--tertiary-low) !important; +} + +.chat-msgactions-hover { + @include unselectable; + position: absolute; + padding-right: 1rem; + padding-top: 0.25rem; + right: 0; + top: -1.5em; + z-index: 2; +} + +.chat-message.is-reply .chat-msgactions-hover { + top: 0.5em; +} + +.chat-msgactions { + border-radius: 0.25em; + background-color: var(--secondary); + display: flex; + + .emoji-picker-anchor { + position: absolute; + height: 34px; + } + + .link-to-message-btn { + .d-icon { + transition: all 0.25s ease-in-out; + } + + &.copied { + .d-icon { + transform: scale(1.1); + color: var(--tertiary); + } + } + } + + .react-btn, + .reply-btn, + .bookmark-btn { + margin-right: -1px; + padding: 0.5em 0; + width: 2.5em; + transition: background 0.2s, border-color 0.2s; + + &:focus { + .d-icon { + color: var(--primary); + } + } + + &:first-child { + border-bottom-left-radius: 0.25em; + border-top-left-radius: 0.25em; + } + + &:first-child:not(:hover) { + border-color: var(--primary-low); + border-right-color: transparent; + } + + .d-icon { + color: var(--primary-low-mid); + + &.bookmark-icon__bookmarked { + color: var(--tertiary); + } + } + } + + .reply-btn { + .d-icon { + color: var(--tertiary); + } + } + + .more-buttons.dropdown-select-box { + .select-kit-header { + background: none; + border: 1px solid var(--primary-low); + border-left-color: transparent; + border-radius: 0 0.25em 0.25em 0; + padding: 0.5em 0; + width: 2.5em; + transition: background 0.2s, border-color 0.2s; + + &:focus { + border-color: var(--primary-low); + border-left-color: transparent; + background: var(--primary-low); + + .select-kit-header-wrapper .d-icon { + color: var(--primary); + } + } + + .select-kit-header-wrapper { + justify-content: center; + + .d-icon { + color: var(--primary-low-mid); + margin: 0; + } + } + + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + + .select-kit-header-wrapper { + .d-icon { + color: var(--primary); + } + } + } + } + + .select-kit-body { + padding: 0.5rem; + box-shadow: shadow("card"); + border: 1px solid var(--primary-low); + } + + .select-kit-row { + .texts .name { + font-size: var(--font-0); + font-weight: 500; + } + + .icons .d-icon { + font-size: var(--font-0); + color: var(--primary-medium); + } + } + } + + .chat-message-reaction { + align-items: center; + border-radius: 0; + border-left-color: transparent; + border-right-color: transparent; + box-sizing: border-box; + font-size: var(--font-0); + justify-content: center; + margin: 0; + margin-right: -1px; + padding: 0.5em 0; + width: 2.5em; + + &:hover { + z-index: 2; + } + + &:focus { + background: var(--primary-low); + outline: none; + } + + &:first-child { + border-bottom-left-radius: 0.25em; + border-left-color: var(--primary-low); + border-top-left-radius: 0.25em; + } + + &.reacted { + border-left-color: var(--tertiary-medium); + z-index: 1; + + &:focus { + background: var(--tertiary-low); + } + } + + .emoji { + height: 15px; + width: auto; + margin: 0; + } + } +} + +.chat-messages-container { + .not-mobile-device & .chat-message:hover, + .chat-message.chat-message-selected { + background: var(--primary-very-low); + } + + .chat-message.chat-message-bookmarked { + background: var(--highlight-low); + } + + .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { + display: none; + } + + .not-mobile-device & .chat-message:hover { + .chat-message-reaction-list .chat-message-react-btn { + display: inline-block; + } + } +} + +.chat-message-flagged { + display: inline-block; + color: var(--danger); + height: 100%; + padding: 0 0.3em; + cursor: pointer; + + .flag-count, + .d-icon { + color: var(--danger); + } +} + +.chat-action-text { + font-style: italic; +} + +.chat-message-container:hover, +.chat-message.chat-message-selected { + background: var(--primary-very-low); +} + +.chat-message.chat-message-bookmarked { + background: var(--highlight-low); +} + +.has-full-page-chat .chat-message .onebox:not(img), +.topic-chat-float-container .chat-message .onebox { + margin: 0.5em 0; + border-width: 2px; + + header { + margin-bottom: 0.5em; + } + + h3 a, + h4 a { + font-size: 14px; + } + + pre { + display: flex; + max-height: 150px; + } + + p { + overflow: hidden; + } +} + +.topic-chat-float-container .chat-message .onebox { + width: 85%; + border: 2px solid var(--primary-low); + + header { + margin-bottom: 0.5em; + } + + .onebox-body { + grid-template-rows: auto auto auto; + overflow: auto; + } + + h3 { + @include line-clamp(2); + font-weight: 500; + font-size: var(--font-down-1); + } + + p { + display: none; + } +} + +.chat-message-reaction { + > * { + pointer-events: none; + } +} + +.retry-staged-message-btn { + padding: 0.5em 0; + background: none; + + &:hover, + &:focus, + &:active { + background: none !important; + } + + &:focus .retry-staged-message-btn__action { + text-decoration: underline; + } + + .d-icon, + &__title, + &:hover .d-icon { + color: var(--danger) !important; + font-size: var(--font-down-1); + } + + .d-icon { + margin-right: 0.25em !important; + } + + &__action { + color: var(--tertiary); + font-size: var(--font-down-1); + margin-left: 0.25em; + + &:hover { + color: var(--tertiary-high); + text-decoration: underline; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-onebox.scss b/plugins/chat/assets/stylesheets/common/chat-onebox.scss new file mode 100644 index 00000000000..39250f62b23 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-onebox.scss @@ -0,0 +1,34 @@ +.chat-onebox { + .chat-onebox-body { + .chat-onebox-title { + margin-bottom: 3px; + } + + .chat-onebox-description { + color: var(--primary-medium); + } + + .chat-onebox-members-count { + color: var(--primary-medium); + margin-top: 1em; + margin-bottom: 3px; + } + + .chat-onebox-members { + align-items: center; + color: var(--primary-medium); + display: flex; + + .avatar { + aspect-ratio: 30 / 30; + margin-right: 0.25rem; + } + } + } +} + +.chat-transcript { + .chat-transcript-user-avatar .avatar { + aspect-ratio: 20 / 20; + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-reply.scss b/plugins/chat/assets/stylesheets/common/chat-reply.scss new file mode 100644 index 00000000000..765a04d957f --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-reply.scss @@ -0,0 +1,59 @@ +.chat-reply { + display: contents; + align-items: center; + box-sizing: border-box; + font-size: var(--font-down-1); + padding-left: 0.5em; + height: 100%; + width: 100%; + white-space: nowrap; + + .d-icon { + color: var(--primary-low-mid); + } + + .chat-user-presence-flair { + width: 8px; + height: 8px; + right: -1px; + bottom: -1px; + } + + .avatar { + width: 20px; + height: 20px; + } + + .chat-user-avatar { + padding: 0 0.5rem; + } + + .d-icon { + color: var(--primary-low-mid); + } + + &.is-direct-reply { + display: flex; + cursor: pointer; + grid-area: replyto; + } +} + +.chat-reply__excerpt { + @include ellipsis; + color: var(--primary-high); + + > * { + margin-top: 0; + display: inline-block; + } + > p { + margin-top: 0.35em; + } +} + +.chat-reply__username { + @include ellipsis; + font-weight: 700; + padding: 0 0.5em 0 0; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss new file mode 100644 index 00000000000..3d93fc44576 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-replying-indicator.scss @@ -0,0 +1,47 @@ +.chat-replying-indicator-container { + padding: 0 0.5rem; +} + +.chat-replying-indicator { + color: var(--primary-medium); + display: inline-flex; + font-size: var(--font-down-2); + padding-bottom: unquote("max(0px, 0.5rem - env(safe-area-inset-bottom, 0))"); + + &:before { + // unicode zero width space character + // Ensures the span height is consistent even when empty + content: "\200b"; + } + + .chat-replying-indicator__text { + display: inline-flex; + } + + .chat-replying-indicator__wave { + flex: 0 0 auto; + display: inline-flex; + + .chat-replying-indicator__dot { + display: inline-block; + animation: chat-replying-indicator__wave 1.8s linear infinite; + &:nth-child(2) { + animation-delay: -1.6s; + } + &:nth-child(3) { + animation-delay: -1.4s; + } + } + + @keyframes chat-replying-indicator__wave { + 0%, + 60%, + 100% { + transform: initial; + } + 30% { + transform: translateY(-0.2em); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss b/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss new file mode 100644 index 00000000000..d1aeaaf3a91 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-retention-reminder.scss @@ -0,0 +1,35 @@ +.chat-retention-reminder { + display: flex; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + align-items: center; + justify-content: space-between; + background: var(--tertiary-low); + padding: 0.5em 0 0.5em 1em; + font-size: var(--font-down-1); + color: var(--primary); + z-index: 10; + min-width: 280px; + + .btn-flat.dismiss-btn { + margin-left: 0.25em; + color: var(--primary-medium); + + &:hover, + &:focus { + background-color: transparent; + .d-icon { + color: var(--primary); + } + } + .d-icon { + color: var(--primary-medium); + } + } +} + +.full-page-chat .chat-retention-reminder { + top: 4rem; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss new file mode 100644 index 00000000000..2349f67e102 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-selection-manager.scss @@ -0,0 +1,39 @@ +.chat-selection-management { + border-top: 1px solid var(--primary-low); + display: flex; + gap: 0.5rem; + padding: 0.5rem; + + .topic-chat-drawer-content & { + flex-direction: column; + } + + .chat-selection-management-buttons { + display: flex; + gap: 0.5rem; + + .topic-chat-drawer-content & { + flex-direction: column; + width: 100%; + } + } + + .chat-selection-message { + animation: chat-quote-message-background-fade-highlight 2s ease-out 3s; + animation-fill-mode: forwards; + background-color: var(--success-low); + color: var(--primary); + flex: 1; + line-height: normal; + padding: 0.5rem 0.65rem; + } + + @keyframes chat-quote-message-background-fade-highlight { + 0% { + } + 100% { + background-color: transparent; + color: transparent; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-skeleton.scss b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss new file mode 100644 index 00000000000..36beaa46676 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-skeleton.scss @@ -0,0 +1,140 @@ +$radius: 10px; + +.chat-skeleton { + height: auto; + + &__header { + display: flex; + align-items: center; + width: 100%; + padding: 1em; + border-bottom: 1px solid var(--primary-100); + box-sizing: border-box; + } + + &__header-img { + background-color: var(--primary-100); + border-radius: 50%; + width: 20px; + height: 20px; + margin-right: 0.5rem; + } + + &__header-name { + background-color: var(--primary-100); + width: 70px; + height: 18px; + border-radius: $radius; + } + + &__body { + padding: 1em; + } + + &__message { + display: grid; + grid-template: + "avatar poster" + "avatar content" + ". content"; + grid-template-columns: auto 1fr; + + &:not(:first-of-type):not(:last-of-type) { + margin-top: 1.5em; + margin-bottom: 1.5em; + } + } + + &__message-avatar { + grid-area: avatar; + width: 30px; + height: 30px; + border-radius: 50%; + margin-right: 0.5rem; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + } + + &__message-poster { + grid-area: poster; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + width: 70px; + height: 20px; + border-radius: $radius; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + } + + &__message-content { + grid-area: content; + width: 100%; + } + &__message-msg { + height: 13px; + border-radius: $radius; + + .chat-skeleton__message:nth-of-type(odd) & { + background-color: var(--primary-100); + } + .chat-skeleton__message:nth-of-type(even) & { + background-color: var(--primary-200); + } + + &.-line1 { + margin-top: 0.5rem; + margin-bottom: 0.5em; + } + + &.-small { + width: 35%; + } + + &.-medium { + width: 60%; + } + + &.-large { + width: 85%; + } + } + + &.-animation { + position: relative; + overflow: hidden; + + &::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + rgba(var(--chat-skeleton-animation-rgb), 0) 0, + rgba(var(--chat-skeleton-animation-rgb), 0.2) 20%, + rgba(var(--chat-skeleton-animation-rgb), 0.5) 60%, + rgba(var(--chat-skeleton-animation-rgb), 0) + ); + animation: shimmer 1.5s infinite; + content: ""; + } + + @keyframes shimmer { + 100% { + transform: translateX(100%); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-tabs.scss b/plugins/chat/assets/stylesheets/common/chat-tabs.scss new file mode 100644 index 00000000000..fe49815231b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-tabs.scss @@ -0,0 +1,15 @@ +.chat-tabs { + display: flex; + flex-direction: column; + height: 100%; + min-height: 1px; +} + +.chat-tabs__tabpanel { + height: 100%; + min-height: 1px; +} + +.chat-tabs-list { + margin: 1.5rem 0 2rem 1rem; +} diff --git a/plugins/chat/assets/stylesheets/common/chat-transcript.scss b/plugins/chat/assets/stylesheets/common/chat-transcript.scss new file mode 100644 index 00000000000..b66d72fcd68 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-transcript.scss @@ -0,0 +1,78 @@ +.chat-transcript { + @extend .chat-message-container; + min-height: 50px; + padding: 12px; + margin: 1rem 0; + + @include post-aside; + + .chat-messages-container & { + display: block; + } + + &.chat-transcript-chained { + margin: 0; + border-top: 0; + border-bottom: 0; + } + + .chat-transcript-meta { + color: var(--primary-high); + font-size: var(--font-down-2-rem); + border-bottom: 1px solid var(--primary-low); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + } + + .chat-transcript-channel { + font-size: var(--font-down-1-rem); + } + + .chat-transcript-username { + color: var(--primary-high-or-secondary-low); + font-weight: bold; + } + + .chat-transcript-datetime { + color: var(--primary-high); + font-size: var(--font-down-2-rem); + padding: 0 0.5rem; + + a { + color: var(--primary-high); + } + } + + .chat-transcript-messages { + p { + margin: 0.5rem 0; + } + + p:last-of-type { + margin-bottom: 0; + } + } + + .chat-transcript-user { + display: flex; + flex-wrap: wrap-reverse; + gap: 0.25rem 0; + align-items: baseline; + + .chat-transcript-user-avatar { + padding-right: 0.5rem; + } + } + + .chat-transcript-reactions { + margin-top: 0.5em; + + .chat-transcript-reaction { + @include chat-reaction; + } + } + + pre code { + box-sizing: border-box; + } +} diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss new file mode 100644 index 00000000000..d0f1fa2e2df --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -0,0 +1,954 @@ +$float-height: 530px; + +:root { + --message-left-width: 42px; + --full-page-border-radius: 12px; + --full-page-sidebar-width: 275px; +} + +.chat-message-move-to-channel-modal-modal { + .modal-inner-container { + .chat-move-message-channel-chooser { + width: 100%; + .category-chat-badge { + .d-icon { + color: inherit; + } + } + } + } +} + +.uppy-is-drag-over .chat-composer .drop-a-file { + display: flex; + position: absolute; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.75); + z-index: z("header"); + &-content { + width: max-content; + display: flex; + flex-direction: column; + align-items: center; + padding: 2em; + background-color: #1d1d1d; + border-radius: 0.25em; + &-images { + .d-icon { + height: 3em; + width: 3em; + color: var(--secondary-or-primary); + &:first-of-type { + transform: rotate(-5deg); + } + &:nth-of-type(2) { + height: 4em; + width: 4em; + } + &:last-of-type { + transform: rotate(5deg); + } + } + } + &-text { + margin: 1.5em 0 0 0; + font-size: var(--font-up-1); + color: var(--secondary-or-primary); + .d-icon-upload { + padding-right: 0.25em; + position: relative; + bottom: 2px; + color: var(--secondary-or-primary); + } + } + } +} + +.chat-channel-unread-indicator { + @include unselectable; + + width: 14px; + height: 14px; + border-radius: 100%; + box-sizing: content-box; + border: 2px solid var(--secondary); + -webkit-touch-callout: none; + background: var(--tertiary-med-or-tertiary); + font-size: var(--font-down-2); + + &.urgent { + background: var(--success); + color: var(--secondary); + + .number-wrap { + position: relative; + width: 100%; + height: 100%; + + .number { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + } +} + +.header-dropdown-toggle.open-chat { + .icon { + &.active { + .d-icon-comment { + color: var(--primary-medium); + } + } + + &:hover { + .chat-channel-unread-indicator { + border-color: var(--primary-low); + } + } + + .chat-channel-unread-indicator { + border-color: var(--header_background); + position: absolute; + right: 2px; + bottom: 2px; + transition: border-color linear 0.15s; + } + } +} + +.channels-list { + overflow-y: auto; + height: 100%; + padding-bottom: env(safe-area-inset-bottom); + position: relative; + @include chat-scrollbar(var(--secondary)); + + @include breakpoint(mobile-large) { + @include chat-scrollbar(); + } + + .chat-channel-unread-indicator { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 10px; + border: 0; + right: 7px; + top: calc(50% - 5px); + + &.urgent .number-wrap { + display: none; + } + } + + .edit-channel-membership-btn, + .new-dm, + .chat-channel-leave-btn { + background: transparent; + color: var(--primary-medium); + font-size: var(--font-0-rem); + padding: 0.5rem; + + &:hover { + background: transparent; + + .d-icon { + color: var(--primary); + } + } + } + + .public-channel-empty-message { + margin: 0 0.5em 0.5em 0.5em; + } + + .chat-channel-divider { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: bold; + padding: 0.25rem 0.5rem 0.25rem 2rem; + font-family: var(--heading-font-family); + font-size: var(--font-0); + + .channel-title { + line-height: var(--line-height-medium); + } + } + + .edit-channels-dropdown { + .select-kit-header { + background: none; + border: none; + font-size: var(--font-0-rem); + padding: 0.5rem; + + .d-icon { + color: var(--primary-medium); + margin: 0; + } + + &:focus .d-icon, + &:hover .d-icon { + color: var(--primary); + } + } + } + .edit-channel-membership-btn { + &:hover { + background: none; + } + } + + .chat-channel-title { + padding: 0.5rem; + } +} + +.chat-messages-container { + word-wrap: break-word; + white-space: normal; + + .chat-message-container { + display: grid; + + &.selecting-messages { + grid-template-columns: 1.5em 1fr; + } + + .chat-message-selector { + align-self: center; + justify-self: end; + margin: 0; + } + } + + .chat-time { + color: var(--primary-high); + font-size: var(--font-down-2); + } + + .emoji-picker { + position: fixed; + } + + &:hover { + .chat-.chat-message-react-btn { + display: inline-block; + } + } +} + +.chat-emoji-avatar { + width: var(--message-left-width); + align-items: center; + + img { + display: block; + margin-left: auto; + margin-right: auto; + } +} + +.chat-user-avatar { + @include unselectable; + display: flex; + align-items: center; + + .chat-message:not(.is-reply) & { + width: var(--message-left-width); + flex-shrink: 0; + } + + &.is-online { + .chat-user-avatar-container .avatar { + box-shadow: 0px 0px 0px 1px var(--success); + border: 1px solid var(--secondary); + padding: 0; + } + } + + .chat-user-avatar-container { + position: relative; + + .avatar { + padding: 1px; + } + + .chat-user-presence-flair { + box-sizing: border-box; + position: absolute; + background-color: var(--success); + border: 1px solid var(--secondary); + border-radius: 50%; + + .chat-message & { + width: 10px; + height: 10px; + right: 0px; + bottom: 0px; + } + + .chat-channel-title & { + width: 8px; + height: 8px; + right: -1px; + bottom: -1px; + } + } + } + + .chat-channel-title & { + width: auto; + } +} + +.chat-live-pane { + display: flex; + flex-direction: column; + width: 100%; + min-height: 1px; + + .chat-messages-scroll { + flex-grow: 1; + overflow-y: scroll; + scrollbar-color: var(--primary-low) transparent; + transition: scrollbar-color 0.2s ease-in-out; + display: flex; + flex-direction: column-reverse; + z-index: 1; + + &::-webkit-scrollbar { + width: 15px; + } + &::-webkit-scrollbar-thumb { + background: var(--primary-low); + border-radius: 8px; + border: 3px solid var(--secondary); + } + &::-webkit-scrollbar-track { + background-color: transparent; + } + &:hover { + scrollbar-color: var(--primary-low-mid) transparent; + &::-webkit-scrollbar-thumb { + background: var(--primary-low-mid); + } + } + + .join-channel-btn.in-float { + position: absolute; + transform: translateX(-50%); + left: 50%; + top: 10px; + z-index: 10; + } + + .all-loaded-message { + text-align: center; + color: var(--primary-medium); + font-size: var(--font-down-1); + padding: 0.5em 0.25em 0.25em; + } + } + + .scroll-stick-wrap { + position: relative; + } + + .chat-scroll-to-bottom { + background: var(--primary-medium); + bottom: 1em; + border-radius: 100%; + left: 50%; + opacity: 50%; + padding: 0.5em; + position: absolute; + transform: translateX(-50%); + z-index: 2; + + &:hover { + background: var(--primary-medium); + opacity: 100%; + } + + .d-icon { + color: var(--primary); + margin: 0; + } + + &.unread-messages { + opacity: 85%; + border-radius: 0; + transition: border-radius 0.1s linear; + + &:hover { + opacity: 100%; + } + + .d-icon { + margin: 0 0 0 0.5em; + } + } + } +} + +.topic-title-chat-icon { + display: inline-block; + * { + display: inline-block; + } +} + +.chat-channel-row { + align-items: center; + box-sizing: border-box; + display: flex; + position: relative; + cursor: pointer; + color: var(--primary-high); + transition: opacity 50ms ease-in; + opacity: 1; + + .chat-channel-unread-indicator { + margin-left: 0.5rem; + } + + &.unfollowing { + opacity: 0; + } + + .toggle-channel-membership-button.-leave { + visibility: hidden; + margin-left: auto; + } + + &:hover { + .toggle-channel-membership-button.-leave { + visibility: visible; + + > * { + pointer-events: auto; + } + } + } + + .discourse-no-touch &:hover, + &.active { + background: var(--primary-low); + } + + &:hover, + &.active { + .topic-chat-badge .topic-chat-icon .d-icon { + background: transparent; + } + &.active { + font-weight: 600; + } + + .chat-channel-unread-indicator { + border-color: var(--primary-low); + } + + .chat-channel-title { + &, + .category-chat-name, + .dm-usernames { + color: var(--primary); + } + + .d-icon-lock { + background-color: var(--primary-low); + } + } + } + + &.muted { + opacity: 0.65; + } + .badge-wrapper { + align-items: center; + margin-right: 0; + } + + .chat-channel-unread-indicator { + background: var(--tertiary-med-or-tertiary); + + &.urgent { + background: var(--success); + } + } + + .chat-channel-row-unread-count { + display: inline-block; + margin-left: 5px; + font-size: var(--font-down-1); + color: var(--primary-high); + } + + .emoji { + margin-left: 0.3em; + } +} + +.chat-channel-settings-row { + display: flex; + padding: 0.5em; + align-items: center; + background: var(--secondary); + border-bottom: 1px solid var(--primary-low); + .chat-channel-info { + .channel-title-container { + position: relative; + .channel-title { + display: flex; + align-items: center; + font-weight: 500; + .edit-btn { + border: none; + background-color: var(--secondary); + &:hover { + .d-icon { + color: var(--primary-very-high); + } + } + .d-icon { + color: var(--primary-medium); + } + .select-kit-header { + background-color: var(--secondary); + } + } + } + } + .chat-channel-data { + display: flex; + align-items: center; + font-size: var(--font-down-1); + .d-icon-check { + font-size: var(--font-down-3); + margin-right: 0.5em; + color: var(--success); + } + .channel-joined { + margin: 0 0.5em 0 0; + font-weight: 500; + color: var(--success); + } + .chat-channel-description { + color: var(--primary-high); + } + } + } + .btn-container { + margin-left: auto; + } +} + +.chat-channel-settings-row { + .channel-name-edit { + display: flex; + align-items: center; + margin-bottom: 9px; + + .name-input { + margin: 0; + } + + .save-btn, + .cancel-btn { + margin-left: 0.25em; + } + } +} + +body.has-sidebar-page.has-full-page-chat #main-outlet-wrapper { + gap: 0; +} + +body.has-full-page-chat { + .alert-error, + .alert-info, + .alert-success, + .alert-warning { + margin: 0; + border-bottom: 1px solid var(--primary-low); + } +} + +.full-page-chat { + font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + display: grid; + grid-template-columns: var(--full-page-sidebar-width) 1fr; + + .channels-list { + height: 100%; + border-right: 1px solid var(--primary-low); + + .chat-channel-row { + padding: 0 0 0 0.5rem; + margin: 0 0.5rem 0.125rem 0.5rem; + border-radius: 0.25em; + + .category-chat-private .d-icon { + background-color: var(--primary-very-low); + } + + &:hover, + &.active { + background-color: var(--primary-low); + .chat-channel-title { + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + .chat-name, + .dm-usernames { + color: var(--primary); + } + } + .category-chat-private .d-icon { + background-color: var(--primary-low); + } + } + } + } + + .chat-full-page-header { + border-top: 1px solid var(--primary-low); + border-bottom: 1px solid var(--primary-low); + background: var(--secondary); + z-index: 3; + + .chat-channel-title { + .category-chat-name, + .topic-chat-name, + .tag-chat-name, + .chat-name, + .dm-usernames { + color: var(--primary); + display: inline; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .-not-following { + .chat-channel-title { + max-width: calc(100% - 50px); + } + .join-channel-btn { + margin-left: auto; + } + } + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-live-pane { + box-sizing: border-box; + height: 100%; + } +} + +.chat-full-page-header__left-actions { + display: flex; + align-items: stretch; +} + +.chat-full-page-header__title { + display: flex; + align-items: stretch; +} + +.chat-full-page-header__right-actions { + align-items: stretch; + display: flex; + flex-grow: 1; + font-size: var(--font-up-1); + justify-content: flex-end; +} + +.chat-full-page-header { + .chat-channel-header-details { + display: flex; + align-items: stretch; + + .chat-channel-archive-status { + text-align: right; + padding-right: 1em; + } + } + + .chat-channel-title { + margin: 0; + max-width: 100%; + + .d-icon:not(.d-icon-lock) { + height: 1.25em; + width: 1.25em; + } + + .category-chat-name, + .dm-username, + .topic-chat-name { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } + + .dm-usernames { + overflow: hidden; + text-overflow: ellipsis; + } + } + .chat-channel-retry-archive { + display: flex; + margin-top: 1em; + } +} + +.channels-list { + .tag-chat-badge, + .category-chat-badge, + .topic-chat-badge { + color: var(--primary-low-mid); + display: flex; + align-items: center; + justify-content: center; + + .d-icon { + height: 1.25em; + width: 1.25em; + margin: 0; + } + } + + .topic-chat-badge { + .d-icon { + z-index: 1; + } + } + + .category-chat-private .d-icon { + background-color: var(--secondary); + position: absolute; + border-radius: 5px; + padding: 3px 2px; + color: var(--primary-high); + height: 0.5em; + width: 0.5em; + left: calc(0.6125em + 6px); + top: -4px; + } +} + +.chat-channel-archive-modal-inner { + .chat-to-topic-selector { + width: 500px; + height: 300px; + } + + .radios { + margin-bottom: 10px; + display: flex; + flex-direction: row; + + .radio-label { + margin-right: 10px; + } + } + + details { + margin-bottom: 9px; + } + + input[type="text"], + .select-kit.combo-box.category-chooser { + width: 100%; + } +} + +.chat-channel-archive-modal-inner { + .chat-to-topic-selector { + width: auto; + } +} + +.user-preferences .chat-setting .controls { + margin-bottom: 0; +} + +.create-channel-modal { + .modal-inner-container { + width: 500px; + } + .choose-topic-results-list { + max-height: 200px; + overflow-y: scroll; + } + .select-kit.combo-box, + .create-channel-name-input, + .create-channel-description-input, + #choose-topic-title { + width: 100%; + margin-bottom: 0; + } + .category-chooser { + .select-kit-selected-name.selected-name.choice { + color: var( + --primary-high + ); // Make consistent with color of placeholder text when choosing topic + } + } + + .create-channel-hint { + font-size: 0.8em; + margin-top: 0.2em; + } + + .create-channel-label, + label[for="choose-topic-title"] { + margin: 1em 0 0.35em; + } + .chat-channel-title { + margin: 1em 0 0 0; + } +} + +.small-action { + .open-chat { + text-transform: uppercase; + font-weight: 700; + font-size-adjust: var(--font-down-0); + } +} + +.chat-message-collapser, +.chat-message-text { + > p { + margin: 0.5em 0 0.5em; + } + + > p:first-of-type { + margin-top: 0.1em; + } + + > p:last-of-type { + margin-bottom: 0.1em; + } +} + +.reviewable-chat-message { + .chat-channel-title { + max-width: 100%; + } +} + +.chat-channel-dm-title { + display: flex; + align-items: center; + justify-content: space-between; + + .channel-name { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } +} + +.chat-channel-status { + padding-top: 1rem; + font-weight: 500; +} + +html.has-full-page-chat { + height: 100%; + width: 100%; + + &.keyboard-visible body #main-outlet .full-page-chat { + padding-bottom: 0.2rem; + } + + body { + height: 100%; + width: 100%; + + #main-outlet { + display: flex; + flex-direction: column; + max-height: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - + var(--composer-height, 0px) + ); + + .full-page-chat { + height: 100%; + min-height: 0; + padding-bottom: env(safe-area-inset-bottom); + } + + #main-chat-outlet { + min-height: 0; + } + } + } + + &.mobile-view { + #main-outlet-wrapper { + padding: 0; + } + } + + // these need to apply to desktop too, because iPads + &.discourse-touch { + // iPad web + #main-outlet-wrapper { + // restrict the row height, including when virtual keyboard is open + grid-template-rows: calc( + var(--chat-vh, 1vh) * 100 - var(--header-offset) + ); + .sidebar-wrapper { + // prevents sidebar from overflowing behind the virtual keyboard + height: 100%; + } + } + + // iPad webview + .footer-nav-ipad { + #main-outlet-wrapper { + // restrict the row height, including when virtual keyboard is open + grid-template-rows: calc( + var(--chat-vh, 1vh) * 100 - calc(var(--header-offset)) + ); + } + } + + .full-page-chat, + .chat-live-pane, + #main-outlet { + // allows containers to shrink to fit + min-height: 0; + } + + #main-outlet { + // limits height for iPad + max-height: calc( + 100vh - calc(var(--header-offset) + var(--composer-ipad-padding)) + ); + } + } +} + +[data-popper-reference-hidden] { + visibility: hidden; +} diff --git a/plugins/chat/assets/stylesheets/common/core-extensions.scss b/plugins/chat/assets/stylesheets/common/core-extensions.scss new file mode 100644 index 00000000000..31330a377f9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/core-extensions.scss @@ -0,0 +1,6 @@ +.has-full-page-chat { + .create-topics-notice, + .bootstrap-mode-notice { + display: none; + } +} diff --git a/plugins/chat/assets/stylesheets/common/d-progress-bar.scss b/plugins/chat/assets/stylesheets/common/d-progress-bar.scss new file mode 100644 index 00000000000..37ade41970b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/d-progress-bar.scss @@ -0,0 +1,51 @@ +// temporary stuff to be moved in core with discourse-loading-slider + +.d-progress-bar-container { + --loading-width: 80%; + --still-loading-width: 90%; + + --still-loading-duration: 10s; + --done-duration: 0.4s; + --fade-out-duration: 0.4s; + + position: absolute; + top: 0; + left: 0; + z-index: z("header") + 2000; + height: 3px; + width: 100%; + opacity: 0; + transition: opacity var(--fade-out-duration) ease var(--done-duration); + background-color: var(--primary-low); + + .d-progress-bar { + height: 100%; + width: 0%; + background-color: var(--tertiary); + } + + &.loading, + &.still-loading { + opacity: 1; + transition: opacity 0s; + } + + &.loading .d-progress-bar { + transition: width var(--loading-duration) ease-in; + width: var(--loading-width); + } + + &.still-loading .d-progress-bar { + transition: width var(--still-loading-duration) linear; + width: var(--still-loading-width); + } + + &.done .d-progress-bar { + transition: width var(--done-duration) ease-out; + width: 100%; + } + + body.footer-nav-ipad & { + top: 49px; // TODO: Share $footer-nav-height from footer-nav.scss + } +} diff --git a/plugins/chat/assets/stylesheets/common/dc-filter-input.scss b/plugins/chat/assets/stylesheets/common/dc-filter-input.scss new file mode 100644 index 00000000000..711cff93f3d --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/dc-filter-input.scss @@ -0,0 +1,23 @@ +.dc-filter-input-container { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--primary-medium); + box-sizing: border-box; + + &.is-focused { + border: 1px solid var(--tertiary); + } + + .dc-filter-input, + .dc-filter-input:focus { + width: 100%; + margin: 0; + border: none; + outline: none; + } + + .d-icon { + margin: 0 0.5rem; + } +} diff --git a/plugins/chat/assets/stylesheets/common/direct-message-creator.scss b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss new file mode 100644 index 00000000000..b7c6e5989a7 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/direct-message-creator.scss @@ -0,0 +1,196 @@ +.direct-message-creator { + display: flex; + flex-direction: column; + + .title-area { + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--primary-low); + + .title { + font-weight: 700; + font-size: var(--font-up-1); + line-height: var(--font-up-1); + } + } + + .filter-area { + padding: 1rem; + display: flex; + align-items: flex-start; + border-bottom: 1px solid var(--primary-low); + cursor: text; + position: relative; + + &.is-focused { + background: var(--primary-very-low); + } + } + + .prefix { + line-height: 34px; + padding-right: 0.25rem; + } + + .selected-user { + list-style: none; + padding: 0; + margin: 1px 0.25rem 0.25rem 1px; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + background: var(--primary-very-low); + border-radius: 8px; + border: 1px solid var(--primary-300); + align-items: center; + display: flex; + + &:last-child { + margin-right: 0; + } + + &.is-highlighted { + border-color: var(--tertiary); + + .d-icon { + color: var(--danger); + } + } + + .username { + margin: 0 0.5em; + } + + & * { + pointer-events: none; + } + + &:hover, + &:focus { + background: var(--primary-very-low); + color: var(--primary); + + &:not(.is-highlighted) { + border-color: var(--tertiary); + } + + .d-icon { + color: var(--danger); + } + } + } + + .recipients { + display: flex; + flex-wrap: wrap; + margin-bottom: -0.25rem; + flex: 1; + min-width: 0; + align-items: center; + + & + .btn { + margin-left: 1em; + } + + .filter-usernames { + flex: 1 0 auto; + min-width: 80px; + margin: 1px 0 0 0; + appearance: none; + border: 0; + outline: 0; + background: none; + width: unset; + } + } + + .results-container { + display: flex; + position: relative; + } + + .results { + display: flex; + margin: 0; + flex-wrap: wrap; + border-bottom: 1px solid var(--primary-low); + box-shadow: shadow("card"); + position: absolute; + width: 100%; + z-index: z("dropdown"); + background: var(--secondary); + + .user { + display: flex; + width: 100%; + list-style: none; + cursor: pointer; + outline: 0; + padding: 0.25em 0.5em; + margin: 0.25rem; + align-items: center; + border-radius: 4px; + + .user-info { + margin: 0; + width: 100%; + } + + &.is-focused { + background: var(--tertiary-very-low); + } + + * { + pointer-events: none; + } + + .username { + margin-left: 0.25em; + color: var(--primary-high); + font-size: var(--font-up-1); + } + + & + .user { + margin-top: 0.25em; + } + + .user-status-message { + margin-left: 0.3em; + + .emoji { + margin-bottom: 0.2em; + } + } + } + + .btn { + padding: 0.25em; + &:last-child { + margin: 0; + } + } + } + + .no-results-container { + position: relative; + } + + .no-results { + text-align: center; + padding: 1rem; + position: absolute; + width: 100%; + box-shadow: shadow("card"); + background: var(--secondary); + margin: 0; + } + + .fetching-preview-message { + padding: 1rem; + text-align: center; + } + + .join-existing-channel { + margin: 1rem auto; + } +} diff --git a/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss b/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss new file mode 100644 index 00000000000..dd2b4c720bb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/full-page-chat-header.scss @@ -0,0 +1,46 @@ +.full-page-chat-header { + display: flex; + padding: 0.25rem; + border-bottom: 1px solid var(--primary-low); + justify-content: space-between; + @include ellipsis; + flex-direction: column; + + .chat-channel-info-link { + justify-self: flex-end; + } +} + +.full-page-chat-header__about-link { + @include ellipsis; + padding-right: 0.25rem; + + .chat-channel-title__name { + font-weight: 700; + } + .chat-channel-title { + padding: 0.5rem 0.5rem 0.25rem 0.5rem; + } +} + +.full-page-chat-header__members-link { + padding: 0 0.5rem 0.5rem 0.5rem; + font-size: var(--font-down-1); + color: var(--primary-medium); + + &:visited { + color: var(--primary-medium); + } +} + +.full-page-chat-header__first-row { + display: flex; + height: 45px; + align-items: center; +} + +.full-page-chat-header__second-row { + display: flex; + height: 32px; + align-items: center; +} diff --git a/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss new file mode 100644 index 00000000000..4b75238fcf7 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/incoming-chat-webhooks.scss @@ -0,0 +1,53 @@ +.incoming-chat-webhooks { + margin-top: 1em; + + &--row { + display: flex; + justify-content: space-between; + background-color: var(--primary-very-low); + padding: 1em; + border-radius: 6px; + margin-bottom: 1em; + + &--details { + display: inline-block; + vertical-align: top; + max-width: calc(100% - 120px - 1em); + + &--name { + font-weight: bold; + font-size: var(--font-up-1); + } + } + &--controls { + display: inline-block; + vertical-align: top; + } + } +} + +.incoming-chat-webhooks-back { + margin-bottom: 1em; +} + +.incoming-chat-webhooks-current-emoji { + padding-left: 0.5em; +} + +.new-incoming-webhook-container { + display: flex; + align-items: center; + + input { + margin: 0; + } + + input, + details { + margin-right: 0.5em; + } + + .create-new-incoming-webhook-btn { + margin-right: 0.25em; + } +} diff --git a/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss b/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss new file mode 100644 index 00000000000..223f015bf8e --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/reviewable-chat-message.scss @@ -0,0 +1,5 @@ +.reviewable-chat-message { + .transcript { + margin: 0 0 1em 0; + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss new file mode 100644 index 00000000000..208312023db --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-channel-title.scss @@ -0,0 +1,8 @@ +.chat-channel-title { + max-width: 96%; + + .direct-message-channels .chat-channel-row:hover & { + overflow: hidden; + max-width: unset; + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss new file mode 100644 index 00000000000..c2211a38e23 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-composer-uploads.scss @@ -0,0 +1,8 @@ +.chat-composer-uploads { + .chat-composer-uploads-container { + .full-page-chat & { + flex-wrap: wrap; + row-gap: 0.5rem; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-composer.scss b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss new file mode 100644 index 00000000000..9ab578cbf85 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-composer.scss @@ -0,0 +1,5 @@ +.chat-composer-container { + .chat-composer { + margin: 0.25rem 10px 0 10px; + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/chat-message.scss b/plugins/chat/assets/stylesheets/desktop/chat-message.scss new file mode 100644 index 00000000000..934d736fa95 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/chat-message.scss @@ -0,0 +1,17 @@ +.chat-msgactions { + .react-btn, + .reply-btn, + .bookmark-btn { + border: 1px solid transparent; + border-bottom-color: var(--primary-low); + border-radius: 0; + border-top-color: var(--primary-low); + + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + color: var(--primary-medium); + z-index: 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/desktop.scss new file mode 100644 index 00000000000..e55781648e9 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/desktop.scss @@ -0,0 +1,168 @@ +.chat-drawer { + width: 400px; + max-width: 100vw; +} + +.user-card, +.group-card { + z-index: z("usercard") + 1; // bump up user card +} + +.full-page-chat { + &.teams-sidebar-on { + grid-template-columns: 1fr; + + .chat-live-pane { + border-radius: var(--full-page-border-radius); + } + } + + .chat-full-page-header { + padding: 1rem; + flex-shrink: 0; + } + + .chat-live-pane { + .chat-messages-container { + .chat-message { + &.is-reply { + grid-template-columns: var(--message-left-width) 1fr; + } + + .chat-user { + width: var(--message-left-width); + } + } + } + } +} + +.chat-message:not(.user-info-hidden) { + padding: 0.65em 1em 0.15em; +} + +.chat-message-text { + img:not(.emoji):not(.avatar) { + transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + + &:hover { + cursor: pointer; + border-radius: 5px; + box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1), + 0 2px 10px 0 rgba(var(--always-black-rgb), 0.1); + } + } +} + +.chat-message.user-info-hidden { + padding: 0.15em 1em; + + .chat-msgactions-hover { + top: -2em; + } +} + +.chat-msgactions[data-popper-reference-hidden], +.chat-msgactions[data-popper-escaped] { + visibility: hidden; + pointer-events: none; +} + +// Full Page Styling in Core +.has-full-page-chat:not(.discourse-sidebar) { + --max-chat-width: 1200px; + + #main-outlet { + max-width: var(--max-chat-width); + padding: 0; + } + + .full-page-chat { + border-right: 1px solid var(--primary-low); + border-left: 1px solid var(--primary-low); + + .channels-list { + background: var(--primary-very-low); + + .chat-channel-divider { + padding: 0.5rem 0.5rem 0 1rem; + } + + .loading-container { + padding-bottom: 1em; + } + } + + .chat-live-pane { + border-radius: unset; + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked) { + background-color: transparent; + } + + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked):hover { + background-color: var(--primary-very-low); + } + } + + @media screen and (max-width: var(--max-chat-width)) { + #main-outlet { + max-width: 100%; + padding: 0; + } + + .full-page-chat { + border: none; + grid-template-columns: 250px 1fr; + } + } +} + +// Full page styling with sidebar enabled +.discourse-sidebar.has-full-page-chat { + #main-outlet { + padding: 2em 0 0 0; + } + + .full-page-chat.teams-sidebar-on { + .chat-live-pane { + border-radius: 0; + } + + .chat-live-pane, + .chat-messages-scroll, + .chat-message:not(.highlighted):not(.deleted):not(.chat-message-bookmarked) { + background: transparent; + } + + .chat-message { + padding-left: 1em; + + &:hover { + background-color: var(--primary-very-low); + } + } + + .chat-messages-container .chat-message-deleted { + padding: 0.25em 1em; + } + } +} + +.chat-browse .chat-channel-settings-row { + .edit-btn, + .btn-container { + opacity: 0; + transition: opacity 0.1s; + } + + &:hover { + .edit-btn, + .btn-container { + opacity: 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss new file mode 100644 index 00000000000..34eda5b38e6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/sidebar-extensions.scss @@ -0,0 +1,4 @@ +.full-page-chat.full-page-chat-sidebar-enabled { + grid-template-columns: 1fr; + overflow: inherit; +} diff --git a/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss new file mode 100644 index 00000000000..0fa99b9fb9f --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/chat-scrollbar.scss @@ -0,0 +1,28 @@ +@mixin chat-scrollbar($border: var(--primary-very-low)) { + --scrollbarBg: transparent; + --scrollbarThumbBg: var(--primary-low); + --scrollbarWidth: 1.2rem; + + scrollbar-color: transparent var(--scrollbarBg); + transition: scrollbar-color 0.25s ease-in-out; + transition-delay: 0.5s; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: calc(var(--scrollbarWidth) / 2); + border: calc(var(--scrollbarWidth) / 4) solid transparent; + } + &:hover { + &::-webkit-scrollbar-thumb { + border: calc(var(--scrollbarWidth) / 4) solid $border; + } + scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); + &::-webkit-scrollbar-thumb { + background-color: var(--scrollbarThumbBg); + } + transition-delay: 0s; + } + &::-webkit-scrollbar { + width: var(--scrollbarWidth); + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-channel-info.scss b/plugins/chat/assets/stylesheets/mobile/chat-channel-info.scss new file mode 100644 index 00000000000..c23348aeb8d --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-channel-info.scss @@ -0,0 +1,5 @@ +.channel-info { + &__back-btn { + margin: 0.5rem 0 1rem 0; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss new file mode 100644 index 00000000000..edbc974c9dd --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-composer.scss @@ -0,0 +1,7 @@ +.chat-composer-container { + padding: 0; + + .chat-composer { + margin: 0.5rem 10px 0 10px; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-index.scss b/plugins/chat/assets/stylesheets/mobile/chat-index.scss new file mode 100644 index 00000000000..730f12ef1f4 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-index.scss @@ -0,0 +1,122 @@ +@import "common/foundation/mixins"; +.full-page-chat { + overflow: hidden; //prevents double scroll + .channels-list { + overflow-y: overlay; + padding-bottom: 6rem; + box-sizing: border-box; + background-color: var(--primary-very-low); + + .direct-message-channels { + .chat-channel-title { + padding: 0.6rem 0; //minor adjustment for visual consistency with channels which dont have avatars + } + } + + @media (hover: none) { + .chat-channel-row:hover { + background: transparent; + } + + .chat-channel-row:active { + background: var(--primary-low); + } + } + + .chat-channel-row { + margin: 0 1.5rem; + padding: 0; + border-radius: 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--primary-low); + } + } + + .chat-channel-divider { + background-color: var(--secondary); + margin-top: 4rem; + padding: 1rem 1.5rem; + font-size: var(--font-up-1); + + &:first-of-type { + margin-top: 0; + padding-bottom: 0.5rem; //visual compensation + } + + .channel-title { + color: var(--quaternary); + font-size: var(--font-down-1); + } + } + + .channels-list-container { + background: var(--secondary); + } + + .chat-user-avatar { + img { + width: calc(var(--chat-mobile-avatar-size) - 2px); + height: calc(var(--chat-mobile-avatar-size) - 2px); + } + + + .chat-channel-title__usernames { + margin-left: 1rem; + } + } + + .chat-channel-title { + padding: 1rem 0; + width: 100%; + overflow: hidden; + + &__users-count { + width: var(--chat-mobile-avatar-size); + height: var(--chat-mobile-avatar-size); + padding: 0; + font-size: var(--font-up-2); + justify-content: center; + + & + .chat-channel-title__name { + margin-left: 1rem; + } + } + + &__name { + font-size: var(--font-up-1); + } + + &__category-badge { + font-size: var(--font-up-1); + } + } + } + + .btn-floating.new-dm { + position: absolute; + background: var(--tertiary); + bottom: 2.5rem; + right: 2.5rem; + border-radius: 50%; + font-size: var(--font-up-4); + padding: 1rem; + transition: transform 0.25s ease, box-shadow 0.25s ease; + z-index: z("usercard"); + box-shadow: 0px 5px 5px -1px rgba(0, 0, 0, 0.25); + + .d-icon { + color: var(--primary-very-low); + } + + &:active { + box-shadow: 0px 0px 5px -1px rgba(0, 0, 0, 0.25); + transform: scale(0.9); + } + + &:focus { + @include default-focus; + border-color: var(--quaternary); + outline-color: var(--quaternary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss new file mode 100644 index 00000000000..d9ae4b07cff --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -0,0 +1,178 @@ +.chat-message-selected { + .chat-msgactions-hover { + bottom: 0; + transition: top 1s linear; + } +} + +.chat-message *, +.chat-composer-row, +.chat-reply, +.replying-text { + @include unselectable; +} + +.chat-message-container { + transform: translateZ(0); +} + +.chat-msgactions-backdrop { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 100%; + width: 100%; + z-index: z("header") + 1; + transition: background-color 0.2s ease; + + .collapse-area { + width: 100%; + height: 100%; + } + + &.fade-in { + background-color: rgba(0, 0, 0, 0.35); + + .chat-msgactions { + bottom: 0; + } + } + + .chat-msgactions { + position: absolute; + bottom: -100vh; + left: 0; + right: 0; + display: flex; + flex-direction: column; + border-radius: 8px 8px 0 0; + margin: 0 2px; + transition: bottom 0.2s ease; + + .selected-message-container { + padding: 0.5em 0.5em 1em 0.5em; + } + + .selected-message { + display: flex; + align-items: center; + padding: 0.5em; + border: 1px solid var(--primary-low); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.125); + border-radius: 8px; + + .selected-message-reply { + &:not(.is-expanded) { + @include ellipsis; + } + + &.is-expanded { + @include user-select(text); + max-height: 80px; + overflow-y: scroll; + } + } + } + + .main-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em 1em 1.5em 1em; + + .chat-message-reaction { + background: none; + border: 1px solid transparent; + + img.emoji { + width: 30px; + height: 30px; + object-fit: contain; + } + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + } + + .react-btn { + .d-icon { + color: var(--primary-medium); + font-size: var(--font-up-4); + } + } + + .chat-message-reaction, + .react-btn { + margin: 0; + } + + .chat-message-reaction, + .reply-btn, + .react-btn, + .bookmark-btn { + flex-grow: 1; + height: 42px; + } + + .bookmark-btn, + .react-btn { + > .svg-icon-title, + > .svg-icon { + font-size: var(--font-up-4); + } + } + + .reply-btn { + border-radius: 3px; + .d-icon { + font-size: var(--font-up-4); + } + } + } + + .secondary-actions { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + padding: 0.5em; + margin: 0; + + .chat-message-action-item { + border-bottom: 1px solid var(--primary-low); + width: 100%; + list-style: none; + padding-bottom: 0.25em; + margin-bottom: 0.25em; + display: flex; + + &:last-child { + border: 0; + margin: 0; + padding: 0; + } + + .chat-message-action { + justify-content: flex-start; + background: none; + width: 100%; + border: 0; + color: var(--primary); + + &:focus, + .d-icon { + color: var(--primary); + } + } + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss new file mode 100644 index 00000000000..97f0643313d --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-selection-manager.scss @@ -0,0 +1,15 @@ +.chat-selection-management { + .chat-selection-management-buttons { + display: flex; + flex-direction: column; + width: 100%; + + .cancel-btn { + margin-left: initial; + } + + .btn { + margin-bottom: 0.25em; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/mobile.scss new file mode 100644 index 00000000000..5afbf04d209 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/mobile.scss @@ -0,0 +1,142 @@ +:root { + --chat-mobile-avatar-size: 38px; +} + +.chat-message { + // 1px to account for .is-online box-shadow + padding: 0.1em 1px; +} + +.chat-message:not(.user-info-hidden) { + padding-top: 0.75em; +} + +body.has-full-page-chat { + .footer-nav { + display: none !important; + } + + #main-outlet { + padding: 0; + } +} + +.chat-channel-settings-modal .modal-inner-container { + max-width: 90vw; + .chat-channel-settings-row { + max-width: 100%; + + .chat-channel-preview { + display: none; + } + .chat-channel-title { + max-width: 80%; + } + .controls.save-container { + justify-content: end; + } + } +} + +.full-page-chat { + grid-template-columns: 100%; + overflow-x: hidden; + width: 100%; + + .chat-live-pane { + border-radius: 0; + padding: 0; + } + + .chat-drawer { + width: 100%; + } + + .chat-full-page-header { + background-color: var(--secondary); + padding: 0.5em 10px; + } + + .chat-messages-scroll { + padding: 0 10px; + } +} + +.channels-list .chat-channel-row { + padding: 0 0.5em 0 0.25em; + + .category-chat-private .d-icon { + background-color: var(--secondary); + } + + .chat-channel-unread-indicator { + width: 6px; + height: 6px; + left: 5px; + top: calc(50% - 3px); + } +} + +.sidebar-container .channels-list .chat-channel-divider { + padding-left: 1em; +} + +.channels-list .chat-channel-divider { + padding: 0.25em 0.5em 0.25em; + margin-top: 1em; +} + +.sidebar-container .channels-list .chat-channel-row { + padding: 0.5em; +} + +.create-channel-modal { + .modal-inner-container { + width: 95%; + } +} + +.chat-browse { + .chat-channel-settings-row { + font-size: var(--font-down-1); + .chat-channel-title { + grid-template-columns: 15px 1fr; + } + } +} + +.chat-full-page-header { + .chat-channel-header-details { + .chat-channel-retry-archive { + flex-direction: column; + + .chat-channel-archive-failed-retry { + margin-top: 0.5em; + } + } + } +} + +html.has-full-page-chat body { + #main-outlet-wrapper { + // restricts the height of the page + grid-template-rows: calc(var(--chat-vh, 1vh) * 100 - var(--header-offset)); + } +} + +.chat-message-separator { + margin-left: 0; +} + +.header-dropdown-toggle.open-chat { + .icon { + &.active { + border: 1px solid var(--primary-low); + background: var(--primary-very-low); + + .d-icon { + color: var(--primary-medium); + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/sidebar-extensions.scss new file mode 100644 index 00000000000..a3826778884 --- /dev/null +++ b/plugins/chat/assets/stylesheets/sidebar-extensions.scss @@ -0,0 +1,168 @@ +// Styles to make channels list in sidebar match sidebar theme +.chat-enabled { + .has-sidebar { + .sidebar-header { + .d-header .menu-panel { + top: calc(3.4em - 2px) !important; + } + .d-header-icons .icon { + width: 2em; + height: 2em; + img.avatar, + #logo-small { + width: 2em; + height: 2em; + } + } + } + .header-dropdown-toggle.open-chat { + .chat-channel-unread-indicator { + border-color: var(--primary-very-low); + } + } + .sidebar-container { + .channels-list { + .chat-channel-divider { + padding: 0 0.5em 0 1.75rem; + } + .chat-channel-row { + padding-right: 0.75em; + } + .chat-channel-leave-btn { + padding: 0; + } + } + } + } + + .sidebar-container { + .channels-list { + color: var(--primary); + font-size: var(--font-down-1); + padding-bottom: 2em; + width: 100%; + overflow-x: hidden; + + .chat-channel-divider { + padding: 0 1.75rem; + + &:hover { + .title-caret { + opacity: 1; + } + } + } + + .channels-list-container { + margin-bottom: 1rem; + } + + .public-channel-empty-message { + margin: 0; + padding: 0em 2em 0.5em; + } + + .chat-channel-row:not(.active) { + &:hover { + .category-chat-private { + .d-icon { + background-color: var(--primary-low); + } + } + } + .category-chat-private { + .d-icon { + background-color: var(--primary-very-low); + } + } + } + + .new-dm, + .edit-channel-membership-btn, + .edit-channels-dropdown .select-kit-header, + .chat-channel-leave-btn { + display: flex; + padding: 0.25em; + border-radius: 0.25em; + + &:hover { + background-color: var(--primary-low); + + .d-icon { + color: var(--primary-medium); + } + } + + .d-icon { + color: var(--primary-medium); + font-size: var(--font-down-1); + padding: 0.25em; + } + } + + .chat-channel-leave-btn { + padding-top: 0; + padding-bottom: 0; + height: 100%; + border-radius: 0; + + &:hover { + .d-icon { + color: var(--primary-medium); + } + } + } + + .chat-channel-row { + padding-left: calc(1.8rem / 2); + margin-left: calc(1.8rem / 2); + border-radius: 0.25em; + padding-right: 1.8rem; + min-height: 28px; + margin-bottom: 0.125rem; + + &:hover { + background-color: var(--primary-low); + } + + .chat-channel-title { + padding: 0.25rem; + font-weight: unset; + margin: 0; + } + } + } + } +} + +.chat-enabled { + .sidebar-section-link-suffix.icon { + &.urgent svg { + color: var(--success); + } + + &.unread svg { + color: var(--tertiary-med-or-tertiary); + } + } + + .sidebar-section-link-prefix { + .prefix-image { + border: 1px solid transparent; + } + + &.active .prefix-image { + box-shadow: 0px 0px 0px 1px var(--success); + } + } + + .sidebar-section-link-content-text { + .user-status { + margin-left: 0.3em; + } + } + + .sidebar-section-link-content-muted { + opacity: 0.65; + } +} diff --git a/plugins/chat/config/locales/client.ar.yml b/plugins/chat/config/locales/client.ar.yml new file mode 100644 index 00000000000..5c75ed4e913 --- /dev/null +++ b/plugins/chat/config/locales/client.ar.yml @@ -0,0 +1,260 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "يتم الآن عرض جميع الرسائل" + already_enabled: "الدردشة مفعَّلة في هذا الموضوع. يُرجى تحديث الصفحة." + disabled_for_topic: "الدردشة متوقفة في هذا الموضوع." + bot: "برنامج روبوت" + create: "إنشاء" + cancel: "إلغاء" + cancel_reply: "إلغاء الرد" + chat_channels: "القنوات" + channel_settings: + edit: "تعديل" + join_channel: "الانضمام إلى القناة" + leave_channel: "مغادرة القناة" + join: "الانضمام" + leave: "مغادرة" + channels_list_popup: + browse: "استعراض القنوات" + click_to_join: "انقر هنا لعرض القنوات المتاحة" + close: "إغلاق" + collapse: "طي ساحب الدردشة" + confirm_flag: "هل تريد بالتأكيد الإبلاغ عن رسالة %{username}؟" + deleted: "تم حذف رسالة. [view]" + delete: "حذف" + edited: "تم التعديل" + empty_state: + direct_message: "يمكنك أيضًا بدء دردشة شخصية مع أحد المستخدمين أو أكثر." + email_frequency: + never: "أبدًا" + enable: "تفعيل الدردشة" + flag: "الإبلاغ" + flagged: "لقد تم الإبلاغ عن الرسالة للمراجعة" + invalid_access: "ليس لديك إذن الوصول لعرض قناة الدردشة هذه" + in_reply_to: "ردًا على" + heading: "الدردشة" + join: "الانضمام" + new_messages: "رسائل جديدة" + mention_warning: + cannot_see: + zero: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليه." + one: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليه." + two: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهما." + few: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + many: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + other: "لا يستطيع %{usernames} الوصول إلى هذه القناة ولم يتم إرسال إشعار إليهم." + dismiss: "تجاهل" + invitations_sent: + zero: "تم إرسال دعوة" + one: "تم إرسال دعوة" + two: "تم إرسال دعوتين" + few: "تم إرسال دعوات" + many: "تم إرسال دعوة" + other: "تم إرسال دعوة" + invite: "دعوة إلى القناة" + without_membership: + zero: "لم ينضم %{usernames} إلى هذه القناة." + one: "لم ينضم %{usernames} إلى هذه القناة." + two: "لم ينضم %{usernames} إلى هذه القناة." + few: "لم ينضم %{usernames} إلى هذه القناة." + many: "لم ينضم %{usernames} إلى هذه القناة." + other: "لم ينضم %{usernames} إلى هذه القناة." + no_public_channels: "لم تنضم إلى أي قنوات." + only_chat_push_notifications: + title: "إرسال الإشعارات الفورية للدردشة فقط" + description: "حظر إرسال جميع الإشعارات الفورية غير المتعلقة بالدردشة" + open: "فتح الدردشة" + open_full_page: "فتح الدردشة في شاشة كاملة" + open_message: "فتح رسالة في الدردشة" + placeholder_self: "تدوين شيء ما" + placeholder_others: "الدردشة مع %{messageRecipient}" + remove_upload: "إزالة ملف" + react: "التفاعل برمز تعبيري" + reply: "رد" + edit: "تعديل" + copy_link: "نسخ الرابط" + rebake_message: "إعادة بناء HTML" + restore: "استعادة الرسالة المحذوفة" + save: "حفظ" + select: "تحديد" + scroll_to_bottom: "التمرير إلى الأسفل" + sound: + title: "صوت إشعارات دردشة سطح المكتب" + sounds: + none: "لا يوجد" + bell: "جرس" + ding: "قرع" + title: "دردشة" + title_capitalized: "الدردشة" + upload: "إرفاق ملف" + uploaded_files: + zero: "%{count} ملف" + one: "ملف واحد (%{count})" + two: "ملفان (%{count})" + few: "%{count} ملفات" + many: "%{count} ملفًا" + other: "%{count} ملف" + you_flagged: "لقد أبلغت عن هذه الرسالة" + exit: "عودة" + browse: + title: القنوات + about_view: + description: الوصف + channel_info: + back_to_channel: "العودة" + channel_selector: + title: "الانتقال إلى القناة" + no_channels: "لا توجد قنوات تطابق بحثك" + create_channel: + choose_category: + label: "اختيار فئة" + none: "اختر واحدة..." + default_hint: إدارة الوصول من خلال زيارة %{category} إعدادات الأمان + create: "إنشاء قناة" + description: "الوصف (اختياري)" + name: "اسم القناة" + type: "النوع" + types: + category: "الفئة" + topic: "الموضوع" + reviewable: + type: "رسالة دردشة" + reactions: + only_you: "لقد تفاعلت باستخدام :%{emoji}:" + and_others: "لقد تفاعلت أنت و%{usernames} باستخدام :%{emoji}:" + only_others: "لقد تفاعل %{usernames} باستخدام :%{emoji}:" + others_and_more: "تفاعل %{usernames} و%{more} من المستخدمين باستخدام :%{emoji}:" + you_others_and_more: "لقد تفاعلت أنت و%{usernames} و%{more} من المستخدمين الآخرين باستخدام :%{emoji}:" + composer: + toggle_toolbar: "تشغيل شريط الأدوات" + notification_levels: + never: "أبدًا" + mention: "للإشارات فقط" + always: "للنشاط بأكمله" + settings: + desktop_notification_level: "إشعارات سطح المكتب" + follow: "الانضمام" + mobile_notification_level: "الإشعارات الفورية للجوَّال" + mute: "كتم القناة" + muted_on: "تشغيل" + muted_off: "إيقاف" + notifications: "الإشعارات" + preview: "معاينة" + save: "حفظ" + saved: "تم الحفظ" + unfollow: "مغادرة" + admin: + title: "الدردشة" + direct_messages: + title: "الدردشة الشخصية" + new: "دردشة شخصية جديدة" + create: "إنشاء" + leave: "مغادرة هذه الدردشة الشخصية" + incoming_webhooks: + back: "العودة" + channel_placeholder: "اختيار قناة" + confirm_destroy: "هل تريد بالتأكيد حذف خطاف الويب الوارد هذا؟ لا يمكن التراجع عن هذا الإجراء." + current_emoji: "الرمز التعبيري الحالي" + description: "الوصف" + delete: "حذف" + emoji: "الرمز التعبيري" + emoji_instructions: "سيتم استخدام الصورة الرمزية للنظام إذا تم ترك الرمز التعبيري فارغًا." + name: "الاسم" + name_placeholder: "الاسم..." + new: "خطاف ويب وارد جديد" + none: "لم يتم إنشاء خطافات ويب واردة حالية." + no_emoji: "لم يتم تحديد رمز تعبيري" + post_to: "نشر إلى" + reset_emoji: "إعادة ضبط الرمز التعبيري" + save: "حفظ" + edit: "تعديل" + select_emoji: "اختيار الرمز التعبيري" + system: "النظام" + title: "خطافات الويب الواردة" + url: "عنوان URL" + username: "اسم المستخدم" + username_instructions: "اسم المستخدم لبرنامج الروبوت الذي ينشر على القناة. يتم ضبطه افتراضيًا على \"النظام\" عند تركه فارغة." + selection: + cancel: "إلغاء" + error: "حدث خطأ في أثناء نقل رسائل الدردشة" + title: "نقل الدردشة إلى الموضوع" + new_topic: + title: "النقل إلى موضوع جديد" + instructions: + zero: "أنت على وشك إنشاء موضوع جديد وتعبئته بعدد %{count} من رسائل الدردشة التي حدَّدتها." + one: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالة الدردشة التي حدَّدتها." + two: "أنت على وشك إنشاء موضوع جديد وتعبئته برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + few: "أنت على وشك إنشاء موضوع جديد وتعبئته بعدد %{count} من رسائل الدردشة التي حدَّدتها." + many: "أنت على وشك إنشاء موضوع جديد وتعبئته بعدد %{count} من رسائل الدردشة التي حدَّدتها." + other: "أنت على وشك إنشاء موضوع جديد وتعبئته بعدد %{count} من رسائل الدردشة التي حدَّدتها." + existing_topic: + title: "النقل إلى موضوع حالي" + instructions: + zero: "يُرجى اختيار الموضوع الذي ترغب في نقل رسائل الدردشة البالغ عددها %{count} إليه." + one: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالة الدردشة إليه." + two: "يُرجى اختيار الموضوع الذي ترغب في نقل رسالتَي الدردشة (%{count}) إليه." + few: "يُرجى اختيار الموضوع الذي ترغب في نقل رسائل الدردشة البالغ عددها %{count} إليه." + many: "يُرجى اختيار الموضوع الذي ترغب في نقل رسائل الدردشة البالغ عددها %{count} إليه." + other: "يُرجى اختيار الموضوع الذي ترغب في نقل رسائل الدردشة البالغ عددها %{count} إليه." + new_message: + title: "النقل إلى رسالة جديدة" + instructions: + zero: "أنت على وشك إنشاء رسالة جديدة وتعبئتها بعدد %{count} من رسائل الدردشة التي حدَّدتها." + one: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالة الدردشة التي حدَّدتها." + two: "أنت على وشك إنشاء رسالة جديدة وتعبئتها برسالتَي الدردشة (%{count}) الذين حدَّدتهما." + few: "أنت على وشك إنشاء رسالة جديدة وتعبئتها بعدد %{count} من رسائل الدردشة التي حدَّدتها." + many: "أنت على وشك إنشاء رسالة جديدة وتعبئتها بعدد %{count} من رسائل الدردشة التي حدَّدتها." + other: "أنت على وشك إنشاء رسالة جديدة وتعبئتها بعدد %{count} من رسائل الدردشة التي حدَّدتها." + replying_indicator: + single_user: "%{username} يكتب" + multiple_users: "%{commaSeparatedUsernames} و%{lastUsername} يكتبون" + many_users: + zero: "%{commaSeparatedUsernames} و%{count} آخر يكتب" + one: "%{commaSeparatedUsernames} و%{count} آخر يكتبان" + two: "%{commaSeparatedUsernames} و%{count} آخران يكتبون" + few: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + many: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + other: "%{commaSeparatedUsernames} و%{count} آخرون يكتبون" + retention_reminders: + public: "يتم الاحتفاظ بسجل القناة لمدة %{days} من الأيام." + dm: "يتم الاحتفاظ بسجل الدردشة الشخصية لمدة %{days} من الأيام." + topic_button_title: "الدردشة" + notifications: + popup: + chat_message: "رسالة دردشة جديدة" + titles: + chat_mention: "إشارة في الدردشة" + chat_invitation: "دعوة الدردشة" + action_codes: + chat: + enabled: 'فعَّل %{who} في %{when}' + disabled: "أغلق %{who} الدردشة في %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: إرسال رسالة دردشة + fields: + chat_channel_id: + label: معرِّف قناة الدردشة + message: + label: رسالة + sender: + label: المرسل + description: يتم ضبطه افتراضيًا على النظام + review: + types: + reviewable_chat_message: + title: "رسالة دردشة تم الإبلاغ عنها" + flagged_by: "تم الإبلاغ بواسطة" + keyboard_shortcuts_help: + chat: + title: "الدردشة" diff --git a/plugins/chat/config/locales/client.be.yml b/plugins/chat/config/locales/client.be.yml new file mode 100644 index 00000000000..2ea77a0d350 --- /dev/null +++ b/plugins/chat/config/locales/client.be.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: diff --git a/plugins/chat/config/locales/client.bg.yml b/plugins/chat/config/locales/client.bg.yml new file mode 100644 index 00000000000..52333529d3c --- /dev/null +++ b/plugins/chat/config/locales/client.bg.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: diff --git a/plugins/chat/config/locales/client.bs_BA.yml b/plugins/chat/config/locales/client.bs_BA.yml new file mode 100644 index 00000000000..828a7e65af8 --- /dev/null +++ b/plugins/chat/config/locales/client.bs_BA.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: diff --git a/plugins/chat/config/locales/client.ca.yml b/plugins/chat/config/locales/client.ca.yml new file mode 100644 index 00000000000..ec737bc1a5b --- /dev/null +++ b/plugins/chat/config/locales/client.ca.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: diff --git a/plugins/chat/config/locales/client.cs.yml b/plugins/chat/config/locales/client.cs.yml new file mode 100644 index 00000000000..041b2f0bd05 --- /dev/null +++ b/plugins/chat/config/locales/client.cs.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: diff --git a/plugins/chat/config/locales/client.da.yml b/plugins/chat/config/locales/client.da.yml new file mode 100644 index 00000000000..02d59a2575b --- /dev/null +++ b/plugins/chat/config/locales/client.da.yml @@ -0,0 +1,165 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Status for chat-kanal ændret" + chat_channel_delete: "Chat-kanal slettet" + api: + scopes: + descriptions: + chat: + create_message: "Opret en chatbesked i en angivet kanal." + chat: + dates: + time_tiny: "tt:mm" + all_loaded: "Viser alle beskeder" + already_enabled: "Chat er allerede aktiveret på dette emne. Opdater venligst." + disabled_for_topic: "Chat er deaktiveret på dette emne." + bot: "bot" + create: "Opret" + cancel: "Annuller" + cancel_reply: "Annuller svar" + chat_channels: "Kanaler" + channel_settings: + title: "Kanal indstillinger" + leave_channel: "Forlad kanal" + channel_archive: + title: "Arkivér Kanal" + retry: "Forsøg igen" + channel_open: + title: "Åbn Kanal" + channel_close: + title: "Luk Kanal" + channel_delete: + title: "Slet Kanal" + confirm_channel_name: "Indtast kanalnavn" + channels_list_popup: + browse: "Gennemse kanaler" + click_to_join: "Klik her for at se tilgængelige kanaler." + close: "Luk" + delete: "Slet" + edited: "redigeret" + enable: "Aktiver chat" + invalid_access: "Du har ikke adgang til at se denne chatkanal" + invitation_notification: "%{username} inviterede dig til at deltage i en chatkanal" + in_reply_to: "Som svar til" + new_messages: "nye beskeder" + bookmark_message: "Bogmærke" + bookmark_message_edit: "Rediger Bogmærke" + save: "Gem" + select: "Vælg" + sounds: + none: "Ingen" + bell: "Klokke" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Vedhæft en fil" + uploaded_files: + one: "%{count} fil" + other: "%{count} filer" + exit: "tilbage" + channel_status: + read_only_header: "Kanalen er skrivebeskyttet" + read_only: "Kun læsning" + archived_header: "Kanalen er arkiveret" + archived: "Arkiveret" + closed: "Lukket" + open_header: "Kanalen er åben" + open: "Åbn" + browse: + title: Kanaler + filter_closed: Lukket + filter_archived: Arkiveret + channel_selector: + title: "Hop til kanal" + no_channels: "Ingen kanaler matcher din søgning" + create_channel: + choose_category: + label: "Vælg en kategori" + none: "vælg en..." + create: "Opret kanal" + description: "Beskrivelse (valgfrit)" + name: "Kanal navn" + type: "Type" + types: + category: "Kategori" + topic: "Emne" + reviewable: + type: "Chat besked" + reactions: + only_you: "Du reagerede med :%{emoji}:" + and_others: "Du, %{usernames} reagerede med :%{emoji}:" + only_others: "%{usernames} reagerede med :%{emoji}:" + others_and_more: "%{usernames} og %{more} andre reagerede med :%{emoji}:" + you_others_and_more: "Du, %{usernames} og %{more} andre reagerede med :%{emoji}:" + composer: + code_text: "kode tekst" + quote: + copy_success: "Chat-citat kopieret til udklipsholderen" + incoming_webhooks: + system: "system" + url: "URL" + username: "Brugernavn" + selection: + cancel: "Annuller" + quote_selection: "Citat i emne" + error: "Der opstod en fejl under flytning af chatbeskeder" + title: "Flyt chat til emne" + new_topic: + title: "Flyt til nyt emne" + existing_topic: + title: "Flyt til eksisterende emne" + new_message: + title: "Flyt til Ny Besked" + replying_indicator: + single_user: "%{username} skriver" + retention_reminders: + public: "Kanalhistorikken gemmes i %{days} dage." + dm: "Personlig chathistorik gemmes i %{days} dage." + topic_button_title: "Chat" + notifications: + chat_invitation_html: "%{username} inviterede dig til at deltage i en chatkanal" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct_html: '%{username} nævnte dig i "%{channel}"' + other_html: '%{username} nævnte %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct_html: "%{username} nævnte dig i personlig chat" + other_html: "%{username} nævnte %{identifier} i personlig chat" + chat_message: "Ny chatbesked" + chat_quoted: "%{username} citerede din chatbesked" + titles: + chat_mention: "Chat omtale" + chat_invitation: "Chat invitation" + chat_quoted: "Chat citeret" + action_codes: + chat: + enabled: '%{who} aktiverede %{when}' + disabled: "%{who} lukkede chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Send chatbesked + fields: + chat_channel_id: + label: Chat kanal ID + message: + label: Besked + sender: + label: Afsender + description: Standard er system + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Skift kanal" diff --git a/plugins/chat/config/locales/client.de.yml b/plugins/chat/config/locales/client.de.yml new file mode 100644 index 00000000000..245f9b8b4b3 --- /dev/null +++ b/plugins/chat/config/locales/client.de.yml @@ -0,0 +1,228 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Alle Nachrichten werden angezeigt" + already_enabled: "Der Chat ist für dieses Thema bereits aktiviert. Bitte aktualisieren." + disabled_for_topic: "Der Chat ist für dieses Thema deaktiviert." + bot: "Bot" + create: "Erstellen" + cancel: "Abbrechen" + cancel_reply: "Antwort verwerfen" + chat_channels: "Kanäle" + channel_settings: + edit: "Bearbeiten" + join_channel: "Kanal beitreten" + leave_channel: "Kanal verlassen" + join: "Beitreten" + leave: "Verlassen" + channels_list_popup: + browse: "Kanäle durchsuchen" + click_to_join: "Klicke hier, um die verfügbaren Kanäle zu sehen." + close: "Schließen" + collapse: "Chat-Bereich ausblenden" + confirm_flag: "Bist du sicher, dass du die Nachricht von %{username} markieren möchtest?" + deleted: "Eine Nachricht wurde gelöscht. [Anzeigen]" + delete: "Löschen" + edited: "bearbeitet" + empty_state: + direct_message: "Du kannst auch einen persönlichen Chat mit einem oder mehreren Benutzern beginnen." + email_frequency: + never: "Niemals" + enable: "Chat aktivieren" + flag: "Melden" + flagged: "Diese Nachricht wurde zur Überprüfung markiert" + invalid_access: "Du bist nicht befugt, diesen Chat-Kanal anzuzeigen" + in_reply_to: "Als Antwort auf" + heading: "Chat" + join: "Beitreten" + new_messages: "neue Nachrichten" + mention_warning: + cannot_see: + one: "%{usernames} kann nicht auf diesen Kanal zugreifen und wurde nicht benachrichtigt." + other: "%{usernames} können nicht auf diesen Kanal zugreifen und wurden nicht benachrichtigt." + dismiss: "verwerfen" + invitations_sent: + one: "Einladung gesendet" + other: "Einladungen gesendet" + invite: "Zum Kanal einladen" + without_membership: + one: "%{usernames} ist diesem Kanal nicht beigetreten." + other: "%{usernames} sind diesem Kanal nicht beigetreten." + no_public_channels: "Du bist keinem Kanal beigetreten." + only_chat_push_notifications: + title: "Nur Chat-Push-Benachrichtigungen senden" + description: "Alle Nicht-Chat-Push-Benachrichtigungen blockieren und nicht senden" + open: "Chat öffnen" + open_full_page: "Vollbild-Chat öffnen" + open_message: "Nachricht im Chat öffnen" + placeholder_self: "Etwas notieren" + placeholder_others: "Chat mit %{messageRecipient}" + remove_upload: "Datei löschen" + react: "Mit Emoji reagieren" + reply: "Antworten" + edit: "Bearbeiten" + copy_link: "Link kopieren" + rebake_message: "HTML neu erstellen" + restore: "Gelöschte Nachricht wiederherstellen" + save: "Speichern" + select: "Auswählen" + scroll_to_bottom: "Nach unten scrollen" + sound: + title: "Desktop-Chat-Benachrichtigungston" + sounds: + none: "Keiner" + bell: "Glocke" + ding: "Ding" + title: "Chat" + title_capitalized: "Chat" + upload: "Datei anhängen" + uploaded_files: + one: "%{count} Datei" + other: "%{count} Dateien" + you_flagged: "Du hast diese Nachricht markiert" + exit: "zurück" + browse: + title: Kanäle + about_view: + description: Beschreibung + channel_info: + back_to_channel: "Zurück" + channel_selector: + title: "Zum Kanal springen" + no_channels: "Keine Kanäle entsprechen deiner Suche" + create_channel: + choose_category: + label: "Kategorie auswählen" + none: "eine auswählen …" + default_hint: Verwalte den Zugang, indem du die Sicherheitseinstellungen für %{category} besuchst + create: "Kanal erstellen" + description: "Beschreibung (optional)" + name: "Kanalname" + type: "Typ" + types: + category: "Kategorie" + topic: "Thema" + reviewable: + type: "Chat-Nachricht" + reactions: + only_you: "Du hast reagiert mit :%{emoji}:" + and_others: "Du, %{usernames} haben reagiert mit :%{emoji}:" + only_others: "%{usernames} haben reagiert mit :%{emoji}:" + others_and_more: "%{usernames} und %{more} andere haben reagiert mit :%{emoji}:" + you_others_and_more: "Du, %{usernames} und %{more} andere haben reagiert mit :%{emoji}:" + composer: + toggle_toolbar: "Symbolleiste umschalten" + notification_levels: + never: "Niemals" + mention: "Nur für Erwähnungen" + always: "Für alle Aktivitäten" + settings: + desktop_notification_level: "Desktop-Benachrichtigungen" + follow: "Beitreten" + mobile_notification_level: "Mobile Push-Benachrichtigungen" + mute: "Kanal stummschalten" + muted_on: "An" + muted_off: "Aus" + notifications: "Benachrichtigungen" + preview: "Vorschau" + save: "Speichern" + saved: "Gespeichert" + unfollow: "Verlassen" + admin: + title: "Chat" + direct_messages: + title: "Persönlicher Chat" + new: "Neuer persönlicher Chat" + create: "Los" + leave: "Diesen persönlichen Chat verlassen" + incoming_webhooks: + back: "Zurück" + channel_placeholder: "Kanal auswählen" + confirm_destroy: "Bist du sicher, dass du diesen eingehenden Webhook löschen willst? Dies kann nicht rückgängig gemacht werden." + current_emoji: "Aktuelles Emoji" + description: "Beschreibung" + delete: "Löschen" + emoji: "Emoji" + emoji_instructions: "Der System-Avatar wird verwendet, wenn Emoji leer gelassen wird." + name: "Name" + name_placeholder: "Name …" + new: "Neuer eingehender Webhook" + none: "Es wurden keine eingehenden Webhooks erstellt." + no_emoji: "Kein Emoji ausgewählt" + post_to: "Veröffentlichen in" + reset_emoji: "Emoji zurücksetzen" + save: "Speichern" + edit: "Bearbeiten" + select_emoji: "Emoji auswählen" + system: "System" + title: "Eingehende Webhooks" + url: "URL" + username: "Benutzername" + username_instructions: "Benutzername des Bots, der etwas im Kanal veröffentlicht. Standardmäßig „System“, wenn das Feld leer gelassen wird." + selection: + cancel: "Abbrechen" + error: "Beim Verschieben der Chat-Nachrichten ist ein Fehler aufgetreten" + title: "Chat in Thema verschieben" + new_topic: + title: "In neues Thema verschieben" + instructions: + one: "Du bist dabei, ein neues Thema zu erstellen und es mit der ausgewählten Chat-Nachricht zu füllen." + other: "Du bist dabei, ein neues Thema zu erstellen und es mit den %{count} ausgewählten Chat-Nachrichten zu füllen." + existing_topic: + title: "In bestehendes Thema verschieben" + instructions: + one: "Bitte wähle das Thema aus, in das du die Chat-Nachricht verschieben möchtest." + other: "Bitte wähle das Thema aus, in das du die %{count} Chat-Nachrichten verschieben möchtest." + new_message: + title: "In neue Nachricht verschieben" + instructions: + one: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit der ausgewählten Chat-Nachricht zu füllen." + other: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit den %{count} ausgewählten Chat-Nachrichten zu füllen." + replying_indicator: + single_user: "%{username} schreibt" + multiple_users: "%{commaSeparatedUsernames} und %{lastUsername} schreiben" + many_users: + one: "%{commaSeparatedUsernames} und %{count} andere Person schreiben" + other: "%{commaSeparatedUsernames} und %{count} andere Personen schreiben" + retention_reminders: + public: "Der Kanalverlauf wird für %{days} Tage gespeichert." + dm: "Der persönliche Chatverlauf wird für %{days} Tage gespeichert." + topic_button_title: "Chat" + notifications: + popup: + chat_message: "Neue Chat-Nachricht" + titles: + chat_mention: "Chat-Erwähnung" + chat_invitation: "Chat-Einladung" + action_codes: + chat: + enabled: '%{who} hat aktiviert %{when}' + disabled: "%{who} hat Chat geschlossen %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Chat-Nachricht senden + fields: + chat_channel_id: + label: Chat-Kanal-ID + message: + label: Nachricht + sender: + label: Absender + description: Standardmäßig System + review: + types: + reviewable_chat_message: + title: "Chat-Nachricht markiert" + flagged_by: "Markiert von" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.el.yml b/plugins/chat/config/locales/client.el.yml new file mode 100644 index 00000000000..d872d0ecc40 --- /dev/null +++ b/plugins/chat/config/locales/client.el.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml new file mode 100644 index 00000000000..7da614ba3eb --- /dev/null +++ b/plugins/chat/config/locales/client.en.yml @@ -0,0 +1,479 @@ +en: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Chat channel status changed" + chat_channel_delete: "Chat channel deleted" + api: + scopes: + descriptions: + chat: + create_message: "Create a chat message in a specified channel." + about: + chat_messages_count: "Chat Messages" + chat_channels_count: "Chat Channels" + chat_users_count: "Chat Users" + + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Showing all messages" + already_enabled: "Chat is already enabled on this topic. Please refresh." + disabled_for_topic: "Chat is disabled on this topic." + bot: "bot" + create: "Create" + cancel: "Cancel" + cancel_reply: "Cancel reply" + chat_channels: "Channels" + browse_all_channels: "Browse all channels" + move_to_channel: + title: "Move messages to channel" + instructions: + one: "You are moving %{count} message. Select a destination channel. A placeholder message will be created in the %{channelTitle} channel to indicate that this message has been moved." + other: "You are moving %{count} messages. Select a destination channel. A placeholder message will be created in the %{channelTitle} channel to indicate that these messages have been moved." + confirm_move: "Move Messages" + channel_settings: + title: "Channel settings" + edit: "Edit" + add: "Add" + close_channel: "Close channel" + open_channel: "Open channel" + archive_channel: "Archive channel" + delete_channel: "Delete channel" + join_channel: "Join channel" + leave_channel: "Leave channel" + join: "Join" + leave: "Leave" + channel_archive: + title: "Archive Channel" + instructions: "

Archiving a channel puts it into read-only mode and moves all messages from the channel into a new or existing topic. No new messages can be sent, and no existing messages can be edited or deleted.

Are you sure you want to archive the %{channelTitle} channel?

" + process_started: "Archiving process has started. This modal will close shortly, and you will receive a personal message when the archive process is complete." + retry: "Retry" + channel_open: + title: "Open Channel" + instructions: "Reopens the channel, all users will be able to send messages and edit their existing messages." + channel_close: + title: "Close Channel" + instructions: "Closing the channel prevents non-staff users from sending new messages or editing existing messages. Are you sure you want to close this channel?" + channel_delete: + title: "Delete Channel" + instructions: "

Deletes the %{name} channel and chat history. All messages and related data, such as reactions and uploads, will be permanently deleted. If you want to preserve the channel history and decomission it, you may want to archive the channel instead.

+

Are you sure you want to permanently delete the channel? To confirm, type the name of the channel in the box below.

" + confirm: "I understand the consequences, delete the channel" + confirm_channel_name: "Enter channel name" + process_started: "The process to delete the channel has started. This modal will close shortly, you will no longer see the deleted channel anywhere." + channels_list_popup: + browse: "Browse channels" + create: "New channel" + + click_to_join: "Click here to view available channels." + close: "Close" + collapse: "Collapse Chat Drawer" + confirm_flag: "Are you sure you want to flag %{username}'s message?" + deleted: "A message was deleted. [view]" + hidden: "A message was hidden. [view]" + delete: "Delete" + edited: "edited" + muted: "muted" + joined: "joined" + empty_state: + direct_message_cta: "Start a personal Chat" + direct_message: "You can also start a personal chat with one or more users." + title: "No channels found" + email_frequency: + description: "We'll only email you if we haven't seen you in the last 15 minutes." + never: "Never" + title: "Email Notifications" + when_away: "Only when away" + enable: "Enable chat" + flag: "Flag" + emoji: "Insert emoji" + flagged: "This message has been flagged for review" + invalid_access: "You don't have access to view this chat channel" + invitation_notification: "%{username} invited you to join a chat channel" + in_reply_to: "In reply to" + heading: "Chat" + join: "Join" + new_messages: "new messages" + mention_warning: + cannot_see: + one: "%{usernames} cannot access this channel and was not notified." + other: "%{usernames} cannot access this channel and were not notified." + dismiss: "dismiss" + invitations_sent: + one: "Invitation sent" + other: "Invitations sent" + invite: "Invite to channel" + without_membership: + one: "%{usernames} has not joined this channel." + other: "%{usernames} have not joined this channel." + aria_roles: + header: "Chat header" + composer: "Chat composer" + channels_list: "Chat channels list" + + no_public_channels: "You have not joined any channels." + only_chat_push_notifications: + title: "Only send chat push notifications" + description: "Block all non-chat push notifications from being sent" + ignore_channel_wide_mention: + title: "Ignore channel-wide mentions" + description: "Do not send notifications for channel-wide mentions (@here and @all)" + open: "Open chat" + open_full_page: "Open full-screen chat" + close_full_page: "Close full-screen chat" + open_message: "Open message in chat" + placeholder_self: "Jot something down" + placeholder_others: "Chat with %{messageRecipient}" + placeholder_new_message_disallowed: "Channel is %{status}, you cannot send new messages right now." + placeholder_silenced: "You cannot send messages at this time." + placeholder_start_conversation: Start a conversation with %{usernames} + remove_upload: "Remove file" + react: "React with emoji" + reply: "Reply" + edit: "Edit" + copy_link: "Copy link" + rebake_message: "Rebuild HTML" + retry_staged_message: + title: "Network error" + action: "Send again?" + unreliable_network: "Network is unreliable, sending messages and saving draft might not work" + bookmark_message: "Bookmark" + bookmark_message_edit: "Edit Bookmark" + restore: "Restore deleted message" + save: "Save" + select: "Select" + silence: "Silence user" + return_to_list: "Return to channels list" + scroll_to_bottom: "Scroll to bottom" + scroll_to_new_messages: "See new messages" + sound: + title: "Desktop chat notification sound" + sounds: + none: "None" + bell: "Bell" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Attach a file" + uploaded_files: + one: "%{count} file" + other: "%{count} files" + you_flagged: "You flagged this message" + exit: "back" + channel_status: + read_only_header: "Channel is read only" + read_only: "Read Only" + archived_header: "Channel is archived" + archived: "Archived" + archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived in the destination topic. Press retry to attempt to complete the archive." + archive_completed: "See the archive topic" + closed_header: "Channel is closed" + closed: "Closed" + open_header: "Channel is open" + open: "Open" + + browse: + title: Channels + filter_all: All + filter_open: Opened + filter_closed: Closed + filter_archived: Archived + filter_input_placeholder: Search channel by name + + chat_message_separator: + today: Today + yesterday: Yesterday + + members_view: + filter_placeholder: Find members + + about_view: + associated_topic: Linked topic + associated_category: Linked category + title: Title + description: Description + + channel_info: + back_to_all_channels: "All channels" + back_to_channel: "Back" + tabs: + about: About + members: Members + settings: Settings + + channel_edit_title_modal: + title: Edit title + input_placeholder: Add a title + description: Give a short descriptive title to your channel + + channel_edit_description_modal: + title: Edit description + input_placeholder: Add a description + description: Tell people what this channel is all about + + direct_message_creator: + title: New Message + prefix: "To:" + no_results: No results + selected_user_title: "Deselect %{username}" + + channel_selector: + title: "Jump to channel" + no_channels: "No channels match your search" + + channel: + no_memberships: This channel has no members + no_memberships_found: No members found + memberships_count: + one: "%{count} member" + other: "%{count} members" + + create_channel: + auto_join_users: + public_category_warning: "%{category} is a public category. Automatically add all recently active users to this channel?" + warning_groups: + one: Automatically add %{members_count} users from %{group}? + other: Automatically add %{members_count} users from %{group} and %{group_2}? + warning_multiple_groups: Automatically add %{members_count} users from %{group_1} and %{count} others? + choose_category: + label: "Choose a category" + none: "select one..." + default_hint: Manage access by visiting %{category} security settings + hint_groups: + one: Users in %{hint} will have access to this channel per the security settings + other: Users in %{hint} and %{hint_2} will have access to this channel per the security settings + hint_multiple_groups: Users in %{hint_1} and %{count} other groups will have access to this channel per the security settings + create: "Create channel" + description: "Description (optional)" + name: "Channel name" + title: "New channel" + type: "Type" + types: + category: "Category" + topic: "Topic" + + reviewable: + type: "Chat message" + + reactions: + only_you: "You reacted with :%{emoji}:" + and_others: "You, %{usernames} reacted with :%{emoji}:" + only_others: "%{usernames} reacted with :%{emoji}:" + others_and_more: "%{usernames} and %{more} others reacted with :%{emoji}:" + you_others_and_more: "You, %{usernames} and %{more} others reacted with :%{emoji}:" + + composer: + toggle_toolbar: "Toggle toolbar" + italic_text: "emphasized text" + bold_text: "strong text" + code_text: "code text" + + quote: + original_channel: 'Originally sent in %{channel}' + copy_success: "Chat quote copied to clipboard" + + notification_levels: + never: "Never" + mention: "Only for mentions" + always: "For all activity" + + settings: + enable_auto_join_users: "Automatically add all recently active users" + disable_auto_join_users: "Stop automatically adding users" + auto_join_users_warning: "Every user who isn't a member of this channel and has access to the %{category} category will join. Are you sure?" + desktop_notification_level: "Desktop notifications" + follow: "Join" + followed: "Joined" + mobile_notification_level: "Mobile push notifications" + mute: "Mute channel" + muted_on: "On" + muted_off: "Off" + notifications: "Notifications" + preview: "Preview" + save: "Save" + saved: "Saved" + unfollow: "Leave" + + admin: + title: "Chat" + + direct_messages: + title: "Personal chat" + new: "New personal chat" + create: "Go" + leave: "Leave this personal chat" + cannot_create: "Sorry, you cannot send direct messages." + + incoming_webhooks: + back: "Back" + channel_placeholder: "Select a channel" + confirm_destroy: "Are you sure you want to delete this incoming webhook? This cannot be un-done." + current_emoji: "Current Emoji" + description: "Description" + delete: "Delete" + emoji: "Emoji" + emoji_instructions: "System avatar will be used if emoji is left blank." + name: "Name" + name_placeholder: "name..." + new: "New incoming webhook" + none: "No existing incoming webhooks created." + no_emoji: "No Emoji selected" + post_to: "Post to" + reset_emoji: "Reset Emoji" + save: "Save" + edit: "Edit" + select_emoji: "Choose Emoji" + system: "system" + title: "Incoming webhooks" + url: "URL" + url_instructions: "This URL contains a secret value - keep it safe." + username: "Username" + username_instructions: "Username of bot that posts to channel. Defaults to 'system' when left blank." + instructions: "Incoming webhooks can be used by external systems to post messages into a designated chat channel as a bot user via the /hooks/:key endpoint. The payload consists of a single text parameter, which is limited to 2000 characters.

We also support limited Slack-formatted text parameters, extracting links and mentions based on the format at https://api.slack.com/reference/surfaces/formatting, but the /hooks/:key/slack endpoint must be used for this." + + selection: + cancel: "Cancel" + quote_selection: "Quote in Topic" + copy: "Copy" + move_selection_to_channel: "Move to Channel" + error: "There was an error moving the chat messages" + title: "Move Chat to Topic" + new_topic: + title: "Move to New Topic" + instructions: + one: "You are about to create a new topic and populate it with the chat message you've selected." + other: "You are about to create a new topic and populate it with the %{count} chat messages you've selected." + instructions_channel_archive: "You are about to create a new topic and archive the channel messages to it." + existing_topic: + title: "Move to Existing Topic" + instructions: + one: "Please choose the topic you'd like to move that chat message to." + other: "Please choose the topic you'd like to move those %{count} chat messages to." + instructions_channel_archive: "Please choose the topic you'd like to archive the channel messages to." + new_message: + title: "Move to New Message" + instructions: + one: "You are about to create a new message and populate it with the chat message you've selected." + other: "You are about to create a new message and populate it with the %{count} chat messages you've selected." + + replying_indicator: + single_user: "%{username} is typing" + multiple_users: "%{commaSeparatedUsernames} and %{lastUsername} are typing" + many_users: + one: "%{commaSeparatedUsernames} and %{count} other are typing" + other: "%{commaSeparatedUsernames} and %{count} others are typing" + + retention_reminders: + public: "Channel history is retained for %{days} days." + dm: "Personal chat history is retained for %{days} days." + + topic_button_title: "Chat" + + flags: + off_topic: "This message is not relevant to the current discussion as defined by the channel title, and should probably be moved elsewhere." + inappropriate: "This message contains content that a reasonable person would consider offensive, abusive, or a violation of our community guidelines." + spam: "This message is an advertisement, or vandalism. It is not useful or relevant to the current channel." + notify_user: "I want to talk to this person directly and personally about their message." + notify_moderators: "This message requires staff attention for another reason not listed above." + + flagging: + action: "Flag message" + + emoji_picker: + favorites: "Frequently used" + smileys_&_emotion: "Smileys and emotion" + objects: "Objects" + people_&_body: "People and body" + travel_&_places: "Travel and places" + animals_&_nature: "Animals and nature" + food_&_drink: "Food and drink" + activities: "Activities" + flags: "Flags" + symbols: "Symbols" + search_placeholder: "Search by emoji name and alias..." + no_results: "No results" + + draft_channel_screen: + header: "New Message" + cancel: "Cancel" + notifications: + chat_invitation: "invited you to join a chat channel" + chat_invitation_html: "%{username} invited you to join a chat channel" + chat_quoted: "%{username} %{description}" + + popup: + chat_mention: + direct: 'mentioned you in "%{channel}"' + direct_html: '%{username} mentioned you in "%{channel}"' + other_plain: 'mentioned %{identifier} in "%{channel}"' + other_html: '%{username} mentioned %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "mentioned you in personal chat" + direct_html: "%{username} mentioned you in personal chat" + other_plain: "mentioned %{identifier} in personal chat" + other_html: "%{username} mentioned %{identifier} in personal chat" + chat_message: "New chat message" + chat_quoted: "%{username} quoted your chat message" + + titles: + chat_mention: "Chat mention" + chat_invitation: "Chat invitation" + chat_quoted: "Chat quoted" + action_codes: + chat: + enabled: '%{who} enabled %{when}' + disabled: "%{who} closed chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Send chat message + fields: + chat_channel_id: + label: Chat channel ID + message: + label: Message + sender: + label: Sender + description: Defaults to system + review: + transcript: + view: "View previous messages transcript" + types: + reviewable_chat_message: + title: "Flagged Chat Message" + flagged_by: "Flagged By" + keyboard_shortcuts_help: + chat: + title: "Chat" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Switch channel" + open_quick_channel_selector: "%{shortcut} Open quick channel selector" + open_insert_link_modal: "%{shortcut} Insert hyperlink (composer only)" + composer_bold: "%{shortcut} Bold (composer only)" + composer_italic: "%{shortcut} Italic (composer only)" + composer_code: "%{shortcut} Code (composer only)" + drawer_open: "%{shortcut} Open chat drawer" + drawer_close: "%{shortcut} Close chat drawer" + topic_statuses: + chat: + help: "Chat is enabled for this topic" + user: + allow_private_messages: "Allow other users to send me personal messages and chat direct messages" + muted_users_instructions: "Suppress all notifications, personal messages, and chat direct messages from these users." + allowed_pm_users_instructions: "Only allow personal messages or chat direct messages from these users." + allow_private_messages_from_specific_users: "Only allow specific users to send me personal messages or chat direct messages" + ignored_users_instructions: "Suppress all posts, messages, notifications, personal messages, and chat direct messages from these users." + user_menu: + no_chat_notifications_title: "You don’t have any chat notifications yet" + no_chat_notifications_body: > + You will be notified in this panel when someone direct messages you or @mentions you in chat. Notifications will also be sent to your email when you haven’t logged in for a while. +

+ Click the title at the top of any chat channel to configure what notifications you receive in that channel. For more, see your notification preferences. + tabs: + chat_notifications: "Chat notifications" + chat_notifications_with_unread: + one: "Chat notifications - %{count} unread notification" + other: "Chat notifications - %{count} unread notifications" diff --git a/plugins/chat/config/locales/client.en_GB.yml b/plugins/chat/config/locales/client.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/chat/config/locales/client.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/chat/config/locales/client.es.yml b/plugins/chat/config/locales/client.es.yml new file mode 100644 index 00000000000..8d10b37422d --- /dev/null +++ b/plugins/chat/config/locales/client.es.yml @@ -0,0 +1,228 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostrando todos los mensajes" + already_enabled: "El chat ya está activado en este tema. Actualiza." + disabled_for_topic: "El chat está deshabilitado en este tema." + bot: "bot" + create: "Crear" + cancel: "Cancelar" + cancel_reply: "Cancelar respuesta" + chat_channels: "Canales" + channel_settings: + edit: "Editar" + join_channel: "Unirse al canal" + leave_channel: "Abandonar canal" + join: "Unirse" + leave: "Abandonar" + channels_list_popup: + browse: "Examinar canales" + click_to_join: "Haz clic aquí para ver los canales disponibles." + close: "Cerrar" + collapse: "Contraer contenedor del chat" + confirm_flag: "¿Seguro que quieres denunciar el mensaje de %{username}?" + deleted: "Se eliminó un mensaje. [view]" + delete: "Eliminar" + edited: "editado" + empty_state: + direct_message: "También puedes iniciar un chat personal con uno o más usuarios." + email_frequency: + never: "Nunca" + enable: "Habilitar chat" + flag: "Denunciar" + flagged: "Este mensaje ha sido denunciado y se someterá a revisión" + invalid_access: "No tienes acceso para ver este canal de chat" + in_reply_to: "En respuesta a" + heading: "Chat" + join: "Unirse" + new_messages: "nuevos mensajes" + mention_warning: + cannot_see: + one: "%{usernames} no puede acceder a este canal y no fue notificado." + other: "%{usernames} no pueden acceder a este canal y no fueron notificados." + dismiss: "descartar" + invitations_sent: + one: "Invitación enviada" + other: "Invitaciones enviadas" + invite: "Invitar al canal" + without_membership: + one: "%{usernames} no se ha unido a este canal." + other: "%{usernames} no se han unido a este canal." + no_public_channels: "No te has unido a ningún canal." + only_chat_push_notifications: + title: "Enviar solo notificaciones de chat" + description: "Bloquear el envío de todas las notificaciones que no sean de chat" + open: "Abrir chat" + open_full_page: "Abrir chat en pantalla completa" + open_message: "Abrir mensaje en el chat" + placeholder_self: "Anota algo" + placeholder_others: "Chatear con %{messageRecipient}" + remove_upload: "Eliminar archivo" + react: "Reaccionar con emojis" + reply: "Responder" + edit: "Editar" + copy_link: "Copiar enlace" + rebake_message: "Reconstruir HTML" + restore: "Restaurar mensaje eliminado" + save: "Guardar" + select: "Seleccionar" + scroll_to_bottom: "Desplazar hacia abajo" + sound: + title: "Sonido de notificación de chat de escritorio" + sounds: + none: "Ninguno" + bell: "Campana" + ding: "Timbre" + title: "chat" + title_capitalized: "Chat" + upload: "Adjuntar un archivo" + uploaded_files: + one: "%{count} archivo" + other: "%{count} archivos" + you_flagged: "Has denunciado este mensaje" + exit: "atrás" + browse: + title: Canales + about_view: + description: Descripción + channel_info: + back_to_channel: "Atrás" + channel_selector: + title: "Ir al canal" + no_channels: "Ningún canal coincide con tu búsqueda" + create_channel: + choose_category: + label: "Elige una categoría" + none: "selecciona una..." + default_hint: Administra el acceso visitando la %{category} configuración de seguridad + create: "Crear canal" + description: "Descripción (opcional)" + name: "Nombre del canal" + type: "Tipo" + types: + category: "Categoría" + topic: "Tema" + reviewable: + type: "Mensaje de chat" + reactions: + only_you: "Has reaccionado con :%{emoji}:" + and_others: "Tú, %{usernames} reaccionaste con :%{emoji}:" + only_others: "%{usernames} reaccionó con :%{emoji}:" + others_and_more: "%{usernames} y %{more} personas más reaccionaron con :%{emoji}:" + you_others_and_more: "Tú, %{usernames} y %{more} personas más reaccionasteis con :%{emoji}:" + composer: + toggle_toolbar: "Alternar barra de herramientas" + notification_levels: + never: "Nunca" + mention: "Solo para menciones" + always: "Para toda actividad" + settings: + desktop_notification_level: "Notificaciones de escritorio" + follow: "Unirse" + mobile_notification_level: "Notificaciones móviles" + mute: "Silenciar canal" + muted_on: "Activado" + muted_off: "Desactivado" + notifications: "Notificaciones" + preview: "Vista previa" + save: "Guardar" + saved: "Guardado" + unfollow: "Abandonar" + admin: + title: "Chat" + direct_messages: + title: "Chat personal" + new: "Nuevo chat personal" + create: "Ir" + leave: "Abandonar este chat personal" + incoming_webhooks: + back: "Atrás" + channel_placeholder: "Selecciona un canal" + confirm_destroy: "¿Seguro que quieres eliminar este webhook entrante? Esto no se puede deshacer." + current_emoji: "Emoji actual" + description: "Descripción" + delete: "Eliminar" + emoji: "Emoji" + emoji_instructions: "Se usará el avatar del sistema si el emoji se deja en blanco." + name: "Nombre" + name_placeholder: "nombre..." + new: "Nuevo webhook entrante" + none: "No se crearon webhooks entrantes existentes." + no_emoji: "No hay ningún emoji seleccionado" + post_to: "Publicar en" + reset_emoji: "Restablecer emoji" + save: "Guardar" + edit: "Editar" + select_emoji: "Elegir emoji" + system: "sistema" + title: "Webhooks entrantes" + url: "URL" + username: "Nombre de usuario" + username_instructions: "Nombre de usuario del bot que publica en el canal. El valor predeterminado es «sistema» cuando se deja en blanco." + selection: + cancel: "Cancelar" + error: "Se ha producido un error al mover los mensajes de chat" + title: "Mover chat a tema" + new_topic: + title: "Mover a nuevo tema" + instructions: + one: "Estás a punto de crear un nuevo tema y rellenarlo con el mensaje de chat que has seleccionado." + other: "Estás a punto de crear un nuevo tema y rellenarlo con los %{count} mensajes de chat que has seleccionado." + existing_topic: + title: "Mover a un tema existente" + instructions: + one: "Elige el tema al que quieres mover ese mensaje de chat." + other: "Elige el tema al que quieres mover esos %{count} mensajes de chat." + new_message: + title: "Mover a mensaje nuevo" + instructions: + one: "Estás a punto de crear un nuevo mensaje y rellenarlo con el mensaje de chat que has seleccionado." + other: "Estás a punto de crear un nuevo mensaje y rellenarlo con los %{count} mensajes de chat que has seleccionado." + replying_indicator: + single_user: "%{username} está escribiendo" + multiple_users: "%{commaSeparatedUsernames} y %{lastUsername} están escribiendo" + many_users: + one: "%{commaSeparatedUsernames} y %{count} más está escribiendo" + other: "%{commaSeparatedUsernames} y %{count} más están escribiendo" + retention_reminders: + public: "El historial del canal se conserva durante %{days} días." + dm: "El historial de chat personal se conserva durante %{days} días." + topic_button_title: "Chat" + notifications: + popup: + chat_message: "Nuevo mensaje de chat" + titles: + chat_mention: "Mención de chat" + chat_invitation: "Invitación de chat" + action_codes: + chat: + enabled: '%{who} habilitó el %{when}' + disabled: "%{who} cerró el chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensaje de chat + fields: + chat_channel_id: + label: ID del canal de chat + message: + label: Mensaje + sender: + label: Remitente + description: Valores predeterminados del sistema + review: + types: + reviewable_chat_message: + title: "Mensaje de chat denunciado" + flagged_by: "Denunciado por" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.et.yml b/plugins/chat/config/locales/client.et.yml new file mode 100644 index 00000000000..0ea0b6d554b --- /dev/null +++ b/plugins/chat/config/locales/client.et.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: diff --git a/plugins/chat/config/locales/client.fa_IR.yml b/plugins/chat/config/locales/client.fa_IR.yml new file mode 100644 index 00000000000..1382dc2ccdf --- /dev/null +++ b/plugins/chat/config/locales/client.fa_IR.yml @@ -0,0 +1,318 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "وضعیت کانال گفتگو تغییر کرد" + chat_channel_delete: "کانال گفتگو حذف شد" + about: + chat_messages_count: "پیام‌های گفتگو" + chat_channels_count: "کانال‌های گفتگو" + chat_users_count: "کاربران گفتگو" + chat: + dates: + time_tiny: "HH:mm" + all_loaded: "نمایش همه پیام‌ها" + already_enabled: "گفتگو در حال حاضر در این موضوع فعال شده است. لطفا صفحه جاری را تازه‌سازی کنید." + disabled_for_topic: "گفتگو در این موضوع غیرفعال است." + bot: "ربات" + create: "ایجاد" + cancel: "لغو" + cancel_reply: "لغو پاسخ" + chat_channels: "کانال‌ها" + browse_all_channels: "مرور همه کانال‌ها" + move_to_channel: + title: "انتقال پیام‌ها به کانال" + confirm_move: "انتقال پیام‌ها" + channel_settings: + title: "تنظیمات کانال" + edit: "ویرایش" + add: "اضافه کردن" + close_channel: "بستن کانال" + open_channel: "باز کردن کانال" + archive_channel: "بایگانی کانال" + delete_channel: "حذف کانال" + join_channel: "پیوستن به کانال" + leave_channel: "ترک کانال" + join: "پیوستن" + leave: "ترک کردن" + channel_archive: + title: "بایگانی کانال" + retry: "تلاش مجدد" + channel_open: + title: "باز کردن کانال" + instructions: "کانال را باز می‌کند، همه کاربران قادر خواهند بود پیام جدید ارسال کنند و پیام‌های قبلی خود را ویرایش کنند." + channel_close: + title: "بستن کانال" + instructions: "بستن کانال از ارسال پیام‌های جدید یا ویرایش پیام‌های قبلی توسط کاربران که همکار نیستن جلوگیری می‌کند. آیا مطمئنید که می‌خواهید اين کانال رو ببنديد؟" + channel_delete: + title: "حذف کانال" + confirm_channel_name: "نام کانال را وارد کنید" + channels_list_popup: + browse: "مرور کانال‌ها" + create: "کانال جدید" + click_to_join: "برای مشاهده کانال‌های موجود، اینجا را کلیک کنید." + close: "بستن" + confirm_flag: "آیا برای پرچم گذاری پیام %{username} مطمئن هستید؟" + deleted: "یک پیام حذف شد. [view]" + hidden: "یک پیام پنهان شده است. [view]" + delete: "حذف" + edited: "ویرایش شده" + muted: "بی‌صدا شد" + joined: "پیوستن" + empty_state: + direct_message_cta: "شروع گفتگوی شخصی" + direct_message: "شما همچنین می‌توانید یک گفتگو شخصی را با یک یا چند کاربر شروع کنید." + title: "هیچ کانالی پیدا نشد" + email_frequency: + never: "هرگز" + title: "آگاه‌سازی‌های ایمیل" + enable: "فعال کردن گفتگو" + flag: "پرچم" + flagged: "این پیام برای بررسی پرچم گذاری شده است" + invalid_access: "شما برای مشاهده گفتگوی این کانال دسترسی ندارید" + in_reply_to: "در پاسخ به" + heading: "گفتگو" + join: "پیوستن" + new_messages: "پیام‌های جدید" + mention_warning: + invitations_sent: + one: "دعوت‌نامه ارسال شد" + other: "دعوت‌نامه‌ها ارسال شد" + invite: "دعوت به کانال" + without_membership: + one: "%{usernames} هنوز در این کانال عضو نشده است." + other: "%{usernames} هنوز در این کانال عضو نشده است." + aria_roles: + channels_list: "فهرست کانال‌های گفتگو" + no_public_channels: "شما هنوز عضو هیچ کانالی نشده‌اید." + open: "باز کردن گفتگو..." + close_full_page: "بستن گفتگو تمام صفحه" + open_message: "پیام را در گفتگو باز کن" + placeholder_start_conversation: شروع گفتگو با %{usernames} + remove_upload: "حذف پرونده" + react: "واکنش با شکلک" + reply: "پاسخ" + edit: "ویرایش" + copy_link: "کپی پیوند" + retry_staged_message: + title: "خطای شبکه" + action: "دوباره بفرستم؟" + unreliable_network: "شبکه پایدار نیست، ارسال پیام‌ها و ذخیره پیش‌نویس ممکن است کار نکند" + bookmark_message: "نشانک" + bookmark_message_edit: "ویرایش نشانک" + restore: "بازگرداندن پیام حذف شده" + save: "ذخیره" + select: "انتخاب کنید" + return_to_list: "بازگشت به فهرست کانال‌ها" + scroll_to_bottom: "حرکت به پایین" + scroll_to_new_messages: "مشاهده پیام‌های جدید" + sound: + title: "صدای آگاه‌سازی گفتگوی دسکتاپ" + sounds: + none: "هیچکدام" + bell: "زنگ" + ding: "دینگ" + title: "گفتگو" + title_capitalized: "گفتگو" + upload: "پیوست کردن یک پرونده" + uploaded_files: + one: "%{count} پرونده" + other: "%{count} پرونده" + you_flagged: "شما این پیام را پرچم گذاری کردید" + exit: "بازگشت" + channel_status: + read_only: "فقط خواندنی" + archived: "بایگانی شده" + closed_header: "کانال بسته است" + closed: "بسته شد" + open_header: "کانال باز است" + open: "باز کن" + browse: + title: کانال‌ها + filter_all: همه + filter_open: باز شد + filter_closed: بسته شد + filter_archived: بایگانی شد + filter_input_placeholder: کانال را با نام جستجو کنید + chat_message_separator: + today: امروز + yesterday: دیروز + members_view: + filter_placeholder: جستجوی اعضا + about_view: + associated_topic: موضوع مرتبط + associated_category: دسته‌بندی مرتبط + title: عنوان + description: توضیحات + channel_info: + back_to_all_channels: "همه کانال‌ها" + back_to_channel: "بازگشت" + tabs: + about: درباره + members: اعضا + settings: تنظيمات + channel_edit_title_modal: + title: ویرایش عنوان + input_placeholder: افزودن عنوان + description: یک عنوان توصیفی کوتاه به کانال خود بدهید + channel_edit_description_modal: + title: ویرایش توضیحات + input_placeholder: افزودن توضیحات + description: به بقیه افراد بگویید که این کانال در مورد چی هست + direct_message_creator: + title: پیام جدید + prefix: "به:" + no_results: هیج نتیجه‌ای نداشت + channel_selector: + no_channels: "هیچ کانالی با جستجوی شما مطابقت ندارد" + channel: + no_memberships: این کانال هنوز هیچ عضوی ندارد + no_memberships_found: هیچ عضوی یافت نشد + memberships_count: + one: ۱ عضو + other: "%{count} عضو" + create_channel: + auto_join_users: + warning_groups: + one: به طور خودکار %{members_count} کاربر از گروه %{group_1} اضافه شود؟ + other: به طور خودکار %{members_count} کاربر از گروه %{group_1} و %{group_2} اضافه شود؟ + warning_multiple_groups: به طور خودکار %{members_count} کاربر از گروه %{group_1} و %{count} نفر دیگر اضافه شود؟ + choose_category: + label: "انتخاب دسته‌بندی" + none: "یکی را انتخاب کنید..." + create: "ایجاد کانال" + description: "توضیحات «اختیاری»" + name: "نام کانال" + title: "کانال جدید" + type: "نوع" + types: + category: "دسته‌بندی" + topic: "موضوع" + reviewable: + type: "پیام گفتگو" + reactions: + only_you: "شما با :%{emoji}: واکنش نشان دادید" + quote: + copy_success: "نقل قول گفتگو در کلیپ‌بورد کپی شد" + notification_levels: + never: "هرگز" + mention: "فقط برای اشاره کردن" + always: "برای تمام فعالیت‌ها" + settings: + desktop_notification_level: "آگاه‌سازی‌های دسکتاپ" + follow: "پیوستن" + mute: "بی‌صدا کردن کانال" + muted_on: "روشن" + muted_off: "خاموش" + notifications: "آگاه‌سازی" + preview: "پیش‌نمایش" + save: "ذخیره" + saved: "ذخیره شد" + admin: + title: "گفتگو" + direct_messages: + title: "گفتگوی شخصی" + new: "گفتگوی شخصی جدید" + create: "برو" + cannot_create: "با عرض پوزش، شما نمی‌توانید پیام مستقیم ارسال کنید." + incoming_webhooks: + back: "بازگشت" + channel_placeholder: "یک کانال را انتخاب کنید" + current_emoji: "شکلک کنونی" + description: "توضیحات" + delete: "حذف" + emoji: "شکلک" + name: "نام" + name_placeholder: "نام..." + no_emoji: "هیچ شکلکی انتخاب نشده" + reset_emoji: "تنظیم مجدد شکلک" + save: "ذخیره" + edit: "ویرایش" + select_emoji: "انتخاب شکلک" + system: "سیستم" + username: "نام‌کاربری" + selection: + cancel: "انصراف" + quote_selection: "نقل قول در موضوع" + copy: "کپی" + move_selection_to_channel: "انتقال به کانال" + error: "در انتقال پیام‌های گفتگو خطایی رخ داده" + title: "انتقال گفتگو به موضوع" + new_topic: + title: "انتقال به موضوع جدید" + replying_indicator: + single_user: "%{username} در حال نوشتن" + multiple_users: "%{commaSeparatedUsernames} و %{lastUsername} در حال نوشتن" + topic_button_title: "گفتگو" + emoji_picker: + favorites: "اغلب استفاده می‌شه" + objects: "اشیاء" + people_&_body: "مردم و بدن" + travel_&_places: "سفر و مکان‌ها" + animals_&_nature: "حیوانات و طبیعت" + food_&_drink: "غذا و خوراکی" + activities: "فعالیت‌ها" + flags: "پرچم‌ها" + no_results: "هیج نتیجه‌ای نداشت" + notifications: + chat_quoted: "%{username} %{description}" + popup: + chat_message: "پیام گفتگو جدید" + chat_quoted: "%{username} پیام گفتگو شما را نقل کرد" + titles: + chat_mention: "اشاره گفتگو" + chat_invitation: "دعوت‌نامه گفتگو" + action_codes: + chat: + enabled: '%{who} فعال شد %{when}' + disabled: "%{who} گفتگو بسته شد %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: ارسال پیام + fields: + chat_channel_id: + label: شناسه کانال گفتگو + message: + label: پیام + sender: + label: فرستنده + description: پیش‌فرض‌های سیستم + review: + transcript: + view: "مشاهده رونوشت متن پیام‌های قبلی" + types: + reviewable_chat_message: + title: "پیام گفتگوی پرچم گذاری شده" + flagged_by: "پرچم گذاری شده توسط" + keyboard_shortcuts_help: + chat: + title: "گفتگو" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} تغییر کانال" + drawer_open: "%{shortcut} باز کردن کِشوی گفتگو" + drawer_close: "%{shortcut} بستن کِشوی گفتگو" + topic_statuses: + chat: + help: "گفتگو برای این موضوع فعال است" + user: + allow_private_messages: "به دیگر، کاربران اجازه دهید پیام‌های شخصی و پیام‌های مستقیم در گفتگو را برای من ارسال کنند" + muted_users_instructions: "همه آگاه‌سازی‌ها، پیام‌های شخصی و پیام‌های مستقیم در گفتگو از این کاربران را سرکوب کنید." + allowed_pm_users_instructions: "فقط پیام‌های شخصی یا پیام‌های مستقیم در گفتگو را از این کاربران اجازه دهید." + allow_private_messages_from_specific_users: "فقط به کاربران خاصی اجازه دهید، تا پیام‌های شخصی یا پیام‌های مستقیم در گفتگو را برای من ارسال کنند" + ignored_users_instructions: "تمام نوشته‌ها، آگاه‌سازی‌ها، پیام‌های شخصی و پیام‌های مستقیم در گفتگو این کاربران را سرکوب کنید." + user_menu: + no_chat_notifications_title: "شما هنوز هیچ آگاه‌سازی گفتگوی ندارید" + tabs: + chat_notifications: "آگاه‌سازی‌های گفتگو" + chat_notifications_with_unread: + one: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" + other: "آگاه‌سازی‌های گفتگو - %{count} آگاه‌سازی خوانده نشده" diff --git a/plugins/chat/config/locales/client.fi.yml b/plugins/chat/config/locales/client.fi.yml new file mode 100644 index 00000000000..7c77dfbab1e --- /dev/null +++ b/plugins/chat/config/locales/client.fi.yml @@ -0,0 +1,230 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + js: + chat: + dates: + time_tiny: "t:mm" + all_loaded: "Näytetään kaikki viestit" + already_enabled: "Chat on jo käytössä tässä ketjussa. Päivitä." + disabled_for_topic: "Chat on poistettu käytöstä tässä ketjussa." + bot: "botti" + create: "Luo" + cancel: "Peruuta" + cancel_reply: "Peruuta vastaus" + chat_channels: "Kanavat" + channel_settings: + edit: "Muokkaa" + join_channel: "Liity kanavalle" + leave_channel: "Poistu kanavalta" + join: "Liity" + leave: "Poistu" + channels_list_popup: + browse: "Selaa kanavia" + click_to_join: "Näytä saatavilla olevat kanavat napsauttamalla tätä." + close: "Sulje" + collapse: "Tiivistä chat-laatikko" + confirm_flag: "Haluatko varmasti liputtaa käyttäjän %{username} viestin?" + deleted: "Viesti poistettiin. [näytä]" + delete: "Poista" + edited: "muokattu" + empty_state: + direct_message: "Voit myös aloittaa henkilökohtaisen chatin yhden tai useamman käyttäjän kanssa." + email_frequency: + never: "Ei koskaan" + enable: "Ota chat käyttöön" + flag: "Liputa" + flagged: "Tämä viesti on liputettu käsiteltäväksi" + invalid_access: "Sinulla ei ole tämän chat-kanavan katseluoikeutta" + in_reply_to: "Vastauksena:" + heading: "Chat" + join: "Liity" + new_messages: "uusia viestejä" + mention_warning: + cannot_see: + one: "%{usernames} ei voi käyttää tätä kanavaa, eikä hänelle ilmoitettu." + other: "%{usernames} eivät voi käyttää tätä kanavaa, eikä heille ilmoitettu." + dismiss: "hylkää" + invitations_sent: + one: "Kutsu lähetetty" + other: "Kutsut lähetettiin" + invite: "Kutsu kanavalle" + without_membership: + one: "%{usernames} ei ole liittynyt tälle kanavalle." + other: "%{usernames} eivät ole liittyneet tälle kanavalle." + no_public_channels: "Et ole liittynyt kanaville." + only_chat_push_notifications: + title: "Lähetä vain chatin push-ilmoituksia" + description: "Estä kaikkien muiden kuin chatin push-ilmoitusten lähettäminen" + open: "Avaa chat" + open_full_page: "Avaa koko näytön chat" + open_message: "Avaa viesti chatissa" + placeholder_self: "Kirjoita jotakin" + placeholder_others: "Chat-keskustelu käyttäjän %{messageRecipient} kanssa" + remove_upload: "Poista tiedosto" + react: "Reagoi emojilla" + reply: "Vastaa" + edit: "Muokkaa" + copy_link: "Kopioi linkki" + rebake_message: "Kokoa HTML uudelleen" + restore: "Palauta poistettu viesti" + save: "Tallenna" + select: "Valitse" + scroll_to_bottom: "Vieritä alas" + sound: + title: "Työpöytälaitteen chat-ilmoitusääni" + sounds: + none: "Ei mitään" + bell: "Kello" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Liitä tiedosto" + uploaded_files: + one: "%{count} tiedosto" + other: "%{count} tiedostoa" + you_flagged: "Liputit tämän viestin" + exit: "takaisin" + browse: + title: Kanavat + about_view: + description: Kuvaus + channel_info: + back_to_channel: "Takaisin" + channel_selector: + title: "Siirry kanavalle" + no_channels: "Hakuasi vastaavia kanavia ei ole" + create_channel: + choose_category: + label: "Valitse alue" + none: "valitse yksi..." + default_hint: Hallinnoi käyttöoikeutta alueen %{category} turvallisuusasetuksissa + create: "Luo kanava" + description: "Kuvaus (valinnainen)" + name: "Kanavan nimi" + type: "Tyyppi" + types: + category: "Alue" + topic: "Ketju" + reviewable: + type: "Chat-viesti" + reactions: + only_you: "Reagoit emojilla :%{emoji}:" + and_others: "Sinä, %{usernames} reagoitte emojilla :%{emoji}:" + only_others: "%{usernames} reagoivat emojilla :%{emoji}:" + others_and_more: "%{usernames} ja %{more} muuta reagoivat emojilla :%{emoji}:" + you_others_and_more: "Sinä, %{usernames} ja %{more} muuta reagoivat emojilla :%{emoji}:" + composer: + toggle_toolbar: "Vaihda työkalupalkki" + quote: + copy_success: "Chat-lainaus kopioitiin leikepöydälle" + notification_levels: + never: "Ei koskaan" + mention: "Vain maininnoissa" + always: "Kaikessa toiminnassa" + settings: + desktop_notification_level: "Työpöytäilmoitukset" + follow: "Liity" + mobile_notification_level: "Mobiili-push-ilmoitukset" + mute: "Mykistä kanava" + muted_on: "Käytössä" + muted_off: "Pois käytöstä" + notifications: "Ilmoitukset" + preview: "Esikatselu" + save: "Tallenna" + saved: "Tallennettu" + unfollow: "Poistu" + admin: + title: "Chat" + direct_messages: + title: "Henkilökohtainen chat" + new: "Uusi henkilökohtainen chat" + create: "Siirry" + leave: "Poistu tästä henkilökohtaisesta chatista" + incoming_webhooks: + back: "Takaisin" + channel_placeholder: "Valitse kanava" + confirm_destroy: "Haluatko varmasti poistaa tämän saapuvan webhookin? Tätä ei voi peruuttaa." + current_emoji: "Nykyinen emoji" + description: "Kuvaus" + delete: "Poista" + emoji: "Emoji" + emoji_instructions: "Järjestelmän avataria käytetään, jos emoji jätetään tyhjäksi." + name: "Nimi" + name_placeholder: "nimi..." + new: "Uusi saapuva webhook" + none: "Olemassa olevia saapuvia webhookeja ei ole luotu." + no_emoji: "Emojia ei ole valittu" + post_to: "Lähetä kohteeseen" + reset_emoji: "Nollaa emoji" + save: "Tallenna" + edit: "Muokkaa" + select_emoji: "Valitse emoji" + system: "järjestelmä" + title: "Saapuvat webhookit" + url: "URL" + username: "Käyttäjätunnus" + username_instructions: "Kanavalle lähettävän botin käyttäjätunnus. Oletus on \"järjestelmä\", kun tämä jätetään tyhjäksi." + selection: + cancel: "Peruuta" + error: "Chat-viestien siirtämisessä tapahtui virhe" + title: "Siirrä chat ketjuun" + new_topic: + title: "Siirrä uuteen ketjuun" + instructions: + one: "Olet luomassa uutta ketjua ja lisäämässä valitsemasi chat-viestin siihen." + other: "Olet luomassa uutta ketjua ja lisäämässä %{count} valitsemaasi chat-viestiä siihen." + existing_topic: + title: "Siirrä olemassa olevaan ketjuun" + instructions: + one: "Valitse ketju, johon haluat siirtää tämän chat-viestin." + other: "Valitse ketju, johon haluat siirtää nämä %{count} chat-viestiä." + new_message: + title: "Siirrä uuteen viestiin" + instructions: + one: "Olet luomassa uutta viestiä ja lisäämässä valitsemasi chat-viestin siihen." + other: "Olet luomassa uutta viestiä ja lisäämässä %{count} valitsemaasi chat-viestiä siihen." + replying_indicator: + single_user: "%{username} kirjoittaa" + multiple_users: "%{commaSeparatedUsernames} ja %{lastUsername} kirjoittavat" + many_users: + one: "%{commaSeparatedUsernames} ja %{count} muu kirjoittavat" + other: "%{commaSeparatedUsernames} ja %{count} muuta kirjoittavat" + retention_reminders: + public: "Kanavan historia säilytetään %{days} päivän ajan." + dm: "Henkilökohtainen keskusteluhistoria säilytetään %{days} päivän ajan." + topic_button_title: "Chat" + notifications: + popup: + chat_message: "Uusi chat-viesti" + titles: + chat_mention: "Chat-maininta" + chat_invitation: "Chat-kutsu" + action_codes: + chat: + enabled: '%{who} otti käyttöön %{when}' + disabled: "%{who} sulki chatin %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Lähetä chat-viesti + fields: + chat_channel_id: + label: Chat-kanavan tunnus + message: + label: Viesti + sender: + label: Lähettäjä + description: Oletuksena järjestelmä + review: + types: + reviewable_chat_message: + title: "Liputettu chat-viesti" + flagged_by: "Liputtanut" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.fr.yml b/plugins/chat/config/locales/client.fr.yml new file mode 100644 index 00000000000..85f88c8d8ee --- /dev/null +++ b/plugins/chat/config/locales/client.fr.yml @@ -0,0 +1,228 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Affichage de tous les messages" + already_enabled: "Le chat est déjà activé sur ce sujet. Veuillez actualiser." + disabled_for_topic: "Le chat est désactivé sur ce sujet." + bot: "robot" + create: "Créer" + cancel: "Annuler" + cancel_reply: "Annuler la réponse" + chat_channels: "Canaux" + channel_settings: + edit: "Modifier" + join_channel: "Rejoindre le canal" + leave_channel: "Quitter le canal" + join: "Rejoindre" + leave: "Quitter" + channels_list_popup: + browse: "Parcourir les canaux" + click_to_join: "Cliquez ici pour voir les canaux disponibles." + close: "Fermer" + collapse: "Réduire le tiroir de chat" + confirm_flag: "Voulez-vous vraiment signaler le message de %{username} ?" + deleted: "Un message a été supprimé. [view]" + delete: "Supprimer" + edited: "modifié" + empty_state: + direct_message: "Vous pouvez également démarrer une discussion personnelle avec un ou plusieurs utilisateurs." + email_frequency: + never: "Jamais" + enable: "Activer le chat" + flag: "Drapeau" + flagged: "Ce message a été signalé pour examen" + invalid_access: "Vous n'avez pas accès à ce canal de discussion" + in_reply_to: "En réponse à" + heading: "Chat" + join: "Rejoindre" + new_messages: "nouveaux messages" + mention_warning: + cannot_see: + one: "%{usernames} ne peut pas accéder à ce canal et n'a pas été averti(e)." + other: "%{usernames} ne peuvent pas accéder à ce canal et n'ont pas été avertis." + dismiss: "rejeter" + invitations_sent: + one: "Invitation envoyée" + other: "Invitations envoyées" + invite: "Inviter à rejoindre le canal" + without_membership: + one: "%{usernames} n'a pas rejoint ce canal." + other: "%{usernames} n'ont pas rejoint ce canal." + no_public_channels: "Vous n'avez rejoint aucun canal." + only_chat_push_notifications: + title: "Envoyer uniquement des notifications push de chat" + description: "Bloquer l'envoi de toutes les notifications push non liées au chat" + open: "Ouvrir le chat" + open_full_page: "Ouvrir le chat en plein écran" + open_message: "Ouvrir le message dans le chat" + placeholder_self: "Noter quelque chose" + placeholder_others: "Discuter avec %{messageRecipient}" + remove_upload: "Supprimer le fichier" + react: "Réagir avec un émoji" + reply: "Répondre" + edit: "Modifier" + copy_link: "Copier le lien" + rebake_message: "Reconstruire le HTML" + restore: "Restaurer le message supprimé" + save: "Enregistrer" + select: "Sélectionner" + scroll_to_bottom: "Défiler vers le bas" + sound: + title: "Son de notification du chat sur ordinateur" + sounds: + none: "Aucun" + bell: "Cloche" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Joindre un fichier" + uploaded_files: + one: "%{count} fichier" + other: "%{count} fichiers" + you_flagged: "Vous avez signalé ce message" + exit: "retour" + browse: + title: Canaux + about_view: + description: Description + channel_info: + back_to_channel: "Retour" + channel_selector: + title: "Accéder au canal" + no_channels: "Aucun canal ne correspond à votre recherche" + create_channel: + choose_category: + label: "Choisir une catégorie" + none: "sélectionnez-en une…" + default_hint: Gérer l'accès en visitant les paramètres de sécurité de %{category} + create: "Créer un canal" + description: "Description (facultative)" + name: "Nom du canal" + type: "Type" + types: + category: "Catégorie" + topic: "Sujet" + reviewable: + type: "Message de chat" + reactions: + only_you: "Vous avez réagi avec :%{emoji}:" + and_others: "Vous, %{usernames} avez réagi avec :%{emoji}:" + only_others: "%{usernames} a réagi avec :%{emoji} :" + others_and_more: "%{usernames} et %{more} autres utilisateurs ont réagi avec :%{emoji}:" + you_others_and_more: "Vous, %{usernames} et %{more} autres utilisateurs avez réagi avec :%{emoji}:" + composer: + toggle_toolbar: "Basculer la barre d'outils" + notification_levels: + never: "Jamais" + mention: "Seulement pour les mentions" + always: "Pour toutes les activités" + settings: + desktop_notification_level: "Notifications sur le bureau" + follow: "Rejoindre" + mobile_notification_level: "Notifications push mobiles" + mute: "Mettre le canal en sourdine" + muted_on: "Activé" + muted_off: "Désactivé" + notifications: "Notifications" + preview: "Aperçu" + save: "Enregistrer" + saved: "Enregistré" + unfollow: "Quitter" + admin: + title: "Chat" + direct_messages: + title: "Discussion privée" + new: "Nouvelle discussion privée" + create: "Valider" + leave: "Quitter cette discussion privée" + incoming_webhooks: + back: "Retour" + channel_placeholder: "Sélectionnez un canal" + confirm_destroy: "Voulez-vous vraiment supprimer ce webhook entrant ? Cette action est irréversible." + current_emoji: "Émoji actuel" + description: "Description" + delete: "Supprimer" + emoji: "Émoji" + emoji_instructions: "L'avatar du système sera utilisé si l'émoji est laissé vide." + name: "Nom" + name_placeholder: "Nom…" + new: "Nouveau webhook entrant" + none: "Aucun webhook entrant existant n'a été créé." + no_emoji: "Aucun émoji n'a été sélectionné" + post_to: "Publier sur" + reset_emoji: "Réinitialiser l'émoji" + save: "Enregistrer" + edit: "Modifier" + select_emoji: "Choisir un émoji" + system: "système" + title: "Webhooks entrants" + url: "URL" + username: "Nom d'utilisateur" + username_instructions: "Nom d'utilisateur du robot qui publie sur le canal. La valeur par défaut est « système » lorsqu'elle est laissée vide." + selection: + cancel: "Annuler" + error: "Une erreur s'est produite lors du déplacement des messages du chat" + title: "Déplacer le chat vers le sujet" + new_topic: + title: "Déplacer vers un nouveau sujet" + instructions: + one: "Vous êtes sur le point de créer un nouveau sujet et de le remplir avec le message de chat que vous avez sélectionné." + other: "Vous êtes sur le point de créer un nouveau sujet et de le remplir avec les %{count} messages de chat que vous avez sélectionnés." + existing_topic: + title: "Déplacer vers un sujet existant" + instructions: + one: "Veuillez choisir le sujet vers lequel vous souhaitez déplacer ce message de chat." + other: "Veuillez choisir le sujet vers lequel vous souhaitez déplacer ces %{count} messages de chat." + new_message: + title: "Déplacer vers un nouveau message" + instructions: + one: "Vous êtes sur le point de créer un nouveau message et de le remplir avec le message de chat que vous avez sélectionné." + other: "Vous êtes sur le point de créer un nouveau message et de le remplir avec les %{count} messages de chat que vous avez sélectionnés." + replying_indicator: + single_user: "%{username} est en train d'écrire" + multiple_users: "%{commaSeparatedUsernames} et %{lastUsername} sont en train d'écrire" + many_users: + one: "%{commaSeparatedUsernames} et %{count} autre utilisateur sont en train d'écrire" + other: "%{commaSeparatedUsernames} et %{count} autres utilisateurs sont en train d'écrire" + retention_reminders: + public: "L'historique du canal est conservé pendant %{days} jours." + dm: "L'historique du chat personnel est conservé pendant %{days} jours." + topic_button_title: "Chat" + notifications: + popup: + chat_message: "Nouveau message de discussion" + titles: + chat_mention: "Mention de chat" + chat_invitation: "Invitation à rejoindre le chat" + action_codes: + chat: + enabled: '%{who} a activé le %{when}' + disabled: "%{who} a fermé le chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Envoyer un message de chat + fields: + chat_channel_id: + label: ID du canal de discussion + message: + label: Message + sender: + label: Expéditeur + description: Paramètres par défaut + review: + types: + reviewable_chat_message: + title: "Message de chat signalé" + flagged_by: "Signalé par" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.gl.yml b/plugins/chat/config/locales/client.gl.yml new file mode 100644 index 00000000000..fb911ce1635 --- /dev/null +++ b/plugins/chat/config/locales/client.gl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: diff --git a/plugins/chat/config/locales/client.he.yml b/plugins/chat/config/locales/client.he.yml new file mode 100644 index 00000000000..7b856ea6e1e --- /dev/null +++ b/plugins/chat/config/locales/client.he.yml @@ -0,0 +1,472 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "מצב ערוץ הצ׳אט השתנה" + chat_channel_delete: "ערוץ הצ׳אט נמחק" + api: + scopes: + descriptions: + chat: + create_message: "יצירת הודעת צ׳אט בערוץ מסוים." + about: + chat_messages_count: "הודעות צ׳אט" + chat_channels_count: "ערוצי צ׳אט" + chat_users_count: "משתמשי צ׳אט" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "כל ההודעות מוצגות" + already_enabled: "כבר מופעל צ׳אט בנושא הזה. נא לרענן." + disabled_for_topic: "הצ׳אט מושבת בנושא הזה." + bot: "בוט" + create: "יצירה" + cancel: "ביטול" + cancel_reply: "ביטול תגובה" + chat_channels: "ערוצים" + browse_all_channels: "עיון בכל הערוצים" + move_to_channel: + title: "העברת הודעות לערוץ" + instructions: + one: "פעולה זו תעביר הודעה אחת. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעה הזאת הועברה." + two: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + many: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + other: "פעולה זו תעביר %{count} הודעות. יש לבחור ערוץ יעד. הודעה ממלאת מקום תיווצר בערוץ %{channelTitle} כדי לציין שההודעות האלו הועברו." + confirm_move: "העברת הודעות" + channel_settings: + title: "הגדרות ערוץ" + edit: "עריכה" + add: "הוספה" + close_channel: "סגירת ערוץ" + open_channel: "פתיחת ערוץ" + archive_channel: "העברת ערוץ לארכיון" + delete_channel: "מחיקת ערוץ" + join_channel: "הצטרפות לערוץ" + leave_channel: "יציאה מהערוץ" + join: "הצטרפות" + leave: "עזיבה" + channel_archive: + title: "העברת ערוץ לארכיון" + instructions: "

העברת ערוץ לארכיון מעבירה אותו למצב לקריאה בלבד ומעביר את כל ההודעות מהערוץ לנושא חדש או קיים. אי אפשר לשלוח הודעות חדשות ואי אפשר לערוך או למחוק הודעות קיימות.

להעביר את הערוץ ‎%{channelTitle} לארכיון?

" + process_started: "תהליך ההעברה לארכיון החל. חלונית צצה זו תיסגר בקרוב ותישלח אליך הודעה אישית כשתהליך ההעברה לארכיון יסתיים." + retry: "לנסות שוב" + channel_open: + title: "פתיחת ערוץ" + instructions: "פתיחת הערוץ מחדש, כל המשתמשים יוכלו לשלוח הודעות ולערוך את ההודעות הקיימות שלהם." + channel_close: + title: "סגירת ערוץ" + instructions: "סגירת הערוץ מונעת ממשתמשים מחוץ לסגל לשלוח הודעות חדשות או לערוך הודעות קיימות. לסגור את הערוץ הזה?" + channel_delete: + title: "מחיקת ערוץ" + instructions: "

תהליך זה ימחק את הערוץ %{name} ואת היסטוריית ההתכתבות בו. כל ההודעות והנתונים הקשורים כגון תגובות והעלאות יימחקו לחלוטין. אם עדיף לך לשמר את היסטוריית הערוץ ולבטל אותו, אפשר להעביר את הערוץ לארכיון במקום.

למחוק את הערוץ לצמיתות? כדי לאשר, נא למלא את שם הערוץ בתיבה שלהלן.

" + confirm: "ההשלכות ברורות לי, נא למחוק את הערוץ" + confirm_channel_name: "נא למלא את שם הערוץ" + process_started: "תהליך מחיקת הערוץ החל. חלונית צצה זו תיסגר בקרוב, הערוץ שנמחק לא יופיעו עוד בשום מקום." + channels_list_popup: + browse: "עיון בערוצים" + create: "ערוץ חדש" + click_to_join: "לחיצה כאן תציג את הערוצים הזמינים." + close: "סגירה" + collapse: "צמצום מגירת צ׳אט" + confirm_flag: "לסמן את ההודעה של %{username}?" + deleted: "הודעה נמחקה. [צפייה]" + hidden: "הודעה הוסתרה. [צפייה]" + delete: "מחיקה" + edited: "נערך" + muted: "מושתק" + joined: "הצטרפת" + empty_state: + direct_message_cta: "התחלת צ׳אט אישי" + direct_message: "אפשר לפתוח בצ׳אט אישי עם משתמש אחד או יותר." + title: "לא נמצאו ערוצים" + email_frequency: + description: "נשלח לך דוא״ל אם לא ראינו אותך ב־15 הדקות האחרונות." + never: "לעולם לא" + title: "הודעות בדוא״ל" + when_away: "רק כשלא במערכת" + enable: "הפעלת צ׳אט" + flag: "סימון בדגל" + emoji: "הוספת אמוג׳י" + flagged: "הודעה זו סומנה לסקירה" + invalid_access: "אין לך גישה לצפות בערוץ הצ׳אט הזה" + invitation_notification: "הוזמנת להצטרף לערוץ צ׳אט על ידי %{username}" + in_reply_to: "בתגובה אל" + heading: "צ׳אט" + join: "הצטרפות" + new_messages: "הודעות חדשות" + mention_warning: + cannot_see: + one: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + two: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + many: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + other: "ל־%{usernames} אין גישה לערוץ הזה ולא נשלחה הודעה." + dismiss: "התעלמות" + invitations_sent: + one: "נשלחה הזמנה" + two: "נשלחו הזמנות" + many: "נשלחו הזמנות" + other: "נשלחו הזמנות" + invite: "הזמנה לערוץ" + without_membership: + one: "לא הצטרפו לערוץ הזה: %{usernames}." + two: "%{usernames} לא הצטרפו לערוץ הזה." + many: "%{usernames} לא הצטרפו לערוץ הזה." + other: "%{usernames} לא הצטרפו לערוץ הזה." + aria_roles: + header: "כותרת צ׳אט" + composer: "כותב צ׳אט" + channels_list: "רשימת ערוצי צ׳אט" + no_public_channels: "לא הצטרפת לאף ערוץ." + only_chat_push_notifications: + title: "לשלוח התראות בדחיפה על הצ׳אט בלבד" + description: "לחסום שליחה של התראות בדחיפה שאינן לגבי הצ׳אט" + ignore_channel_wide_mention: + title: "התעלמות מאזכורים לכל הערוץ" + description: "לא לשלוח התראות על אזכורים לכל הערוץ (‎@here ו־‎@all)" + open: "פתיחת צ׳אט" + open_full_page: "פתיחת צ׳אט במסך מלא" + close_full_page: "סגירת צ׳אט במסך מלא" + open_message: "פתיחת הודעה בצ׳אט" + placeholder_self: "לקשקש משהו" + placeholder_others: "צ׳אט עם %{messageRecipient}" + placeholder_new_message_disallowed: "הערוץ %{status}, אין לך אפשרות לשלוח הודעות חדשות כעת." + placeholder_silenced: "אין לך אפשרות לשלוח הודעות כרגע." + placeholder_start_conversation: פתיחת דיון עם %{usernames} + remove_upload: "הסרת קובץ" + react: "להגיב עם אמוג׳י" + reply: "תגובה" + edit: "עריכה" + copy_link: "העתקת קישור" + rebake_message: "בניית HTML מחדש" + retry_staged_message: + title: "שגיאת רשת" + action: "לשלוח שוב?" + unreliable_network: "הרשת אינה אמינה, ייתכן ששליחת הודעות ושמירת טיוטה לא תפעלנה" + bookmark_message: "סימון" + bookmark_message_edit: "עריכת סימנייה" + restore: "שחזור הודעה שנמחקה" + save: "שמירה" + select: "בחירה" + silence: "השתקת משתמש" + return_to_list: "חזרה לרשימת הערוצים" + scroll_to_bottom: "גלילה לתחתית" + scroll_to_new_messages: "הצגת הודעות חדשות" + sound: + title: "צליל התראת צ׳אט שולחן עבודה" + sounds: + none: "בלי" + bell: "פעמון" + ding: "דינג" + title: "צ׳אט" + title_capitalized: "צ׳אט" + upload: "צירוף קובץ" + uploaded_files: + one: "קובץ %{count}" + two: "%{count} קבצים" + many: "%{count} קבצים" + other: "%{count} קבצים" + you_flagged: "סימנת את ההודעה הזאת" + exit: "חזרה" + channel_status: + read_only_header: "הערוץ הוא לקריאה בלבד" + read_only: "לקריאה בלבד" + archived_header: "הערוץ בארכיון" + archived: "בארכיון" + archive_failed: "העברת הערוץ לארכיון נכשלה. %{completed}/%{total} הודעות הועברו לארכיון תחת נושא היעד. נא לנסות להשלים את ההעברה לארכיון פעם נוספת." + archive_completed: "אפשר לעיין בארכיון הנושא" + closed_header: "הערוץ סגור" + closed: "סגור" + open_header: "הערוץ פתוח" + open: "פתוח" + browse: + title: ערוצים + filter_all: הכול + filter_open: נפתחו + filter_closed: נסגרו + filter_archived: בארכיון + filter_input_placeholder: חיפוש ערוץ לפי שם + chat_message_separator: + today: היום + yesterday: אתמול + members_view: + filter_placeholder: איתור חברים + about_view: + associated_topic: נושא מקושר + associated_category: קטגוריה מקושרת + title: כותרת + description: תיאור + channel_info: + back_to_all_channels: "כל הערוצים" + back_to_channel: "חזרה" + tabs: + about: על אודות + members: חברים + settings: הגדרות + channel_edit_title_modal: + title: עריכת כותרת + input_placeholder: הוספת כותרת + description: נא לספק כותרת ברורה לערוץ שלך + channel_edit_description_modal: + title: עריכת תיאור + input_placeholder: הוספת תיאור + description: כדי לספר לאנשים על מה הערוץ הזה + direct_message_creator: + title: הודעה חדשה + prefix: "אל:" + no_results: אין תוצאות + selected_user_title: "ביטול בחירת %{username}" + channel_selector: + title: "קפיצה לערוץ" + no_channels: "אין ערוצים שתואמים לחיפוש שלך" + channel: + no_memberships: אין חברים בערוץ הזה + no_memberships_found: לא נמצאו חברים + memberships_count: + one: חבר אחד + two: "%{count} חברים" + many: "%{count} חברים" + other: "%{count} חברים" + create_channel: + auto_join_users: + public_category_warning: "%{category} היא קטגוריה ציבורית. להוסיף את כל המשתמשים שהיו פעילים לאחרונה לערוץ הזה?" + warning_groups: + one: להוסיף %{members_count} משתמשים מתוך %{group_1} אוטומטית? + two: להוסיף %{members_count} משתמשים מתוך %{group_1} ומתוך %{group_2} אוטומטית? + many: להוסיף %{members_count} משתמשים מתוך %{group_1} ומתוך %{group_2} אוטומטית? + other: להוסיף %{members_count} משתמשים מתוך %{group_1} ומתוך %{group_2} אוטומטית? + warning_multiple_groups: להוסיף %{members_count} משתמשים מתוך %{group_1} ועוד %{count} קבוצות אוטומטית? + choose_category: + label: "נא לבחור קטגוריה" + none: "נא לבחור אחד…" + default_hint: ניתן לנהל את הגישה באמצעות ביקור בהגדרות האבטחה של %{category} + hint_groups: + one: למשתמשים ב־%{hint_1} תהיה גישה לערוץ בהתאם להגדרות האבטחה + two: למשתמשים ב־%{hint_1} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + many: למשתמשים ב־%{hint_1} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + other: למשתמשים ב־%{hint_1} וב־%{hint_2} תהיה גישה לערוץ בהתאם להגדרות האבטחה + hint_multiple_groups: למשתמשים ב־%{hint_1} וב־%{count} קבוצות נוספות תהיה גישה לערוץ בהתאם להגדרות האבטחה + create: "יצירת ערוץ" + description: "תיאור (רשות)" + name: "שם הערוץ" + title: "ערוץ חדש" + type: "סוג" + types: + category: "קטגוריה" + topic: "נושא" + reviewable: + type: "הודעת צ׳אט" + reactions: + only_you: "הגבת עם :%{emoji}:" + and_others: "הגבת, יחד עם %{usernames} באמוג׳י :%{emoji}:" + only_others: "%{usernames} הגיבו באמוג׳י :%{emoji}:" + others_and_more: "%{usernames} ו־%{more} נוספים הגיבו באמוג׳י :%{emoji}:" + you_others_and_more: "הגבת, יחד עם %{usernames} ו־%{more} נוספים באמוג׳י :%{emoji}:" + composer: + toggle_toolbar: "החלפת מצב סרגל כלים" + italic_text: "טקסט מודגש" + bold_text: "טקסט בולט" + code_text: "טקסט קוד" + quote: + original_channel: 'נשלח במקור ב־%{channel}' + copy_success: "ציטוט מהצ׳אט הועתק ללוח הגזירים" + notification_levels: + never: "לעולם לא" + mention: "רק אזכורים" + always: "לכל פעילות" + settings: + enable_auto_join_users: "להוסיף אוטומטית את כל המשתמשים שהיו פעילים לאחרונה" + disable_auto_join_users: "להפסיק להוסיף משתמשים אוטומטית" + auto_join_users_warning: "כל משתמש שאינו חבר בערוץ הזה ויש לו גישה לקטגוריה %{category} יצטרף. זה בסדר?" + desktop_notification_level: "התראות שולחן עבודה" + follow: "הצטרפות" + followed: "הצטרפות" + mobile_notification_level: "התראות בדחיפה לנייד" + mute: "השתקת ערוץ" + muted_on: "פעילה" + muted_off: "כבויה" + notifications: "התראות" + preview: "תצוגה מקדימה" + save: "שמירה" + saved: "נשמרו" + unfollow: "לעזוב" + admin: + title: "צ׳אט" + direct_messages: + title: "צ׳אט אישי" + new: "צ׳אט אישי חדש" + create: "קדימה" + leave: "יציאה מהצ׳אט האישי הזה" + cannot_create: "מחילה, אין לך אפשרות לשלוח הודעות ישירות." + incoming_webhooks: + back: "חזרה" + channel_placeholder: "בחירת ערוץ" + confirm_destroy: "למחוק את ההתלייה הנכנסת? אי אפשר לבטל את זה." + current_emoji: "ה־Emoji הנוכחי" + description: "תיאור" + delete: "מחיקה" + emoji: "אמוג׳י" + emoji_instructions: "אם האמוג׳י יישאר ריק ייעשה שימוש בתמונה הייצוגית של המערכת." + name: "שם" + name_placeholder: "שם…" + new: "התליה נכנסת חדשה" + none: "לא נוצרו התליות נכנסות קיימות." + no_emoji: "לא נבחר אמוג׳י" + post_to: "פרסום אל" + reset_emoji: "איפוס אמוג׳י" + save: "שמירה" + edit: "עריכה" + select_emoji: "בחירת אמוג׳י" + system: "מערכת" + title: "התליות נכנסות" + url: "כתובת" + url_instructions: "כתובת זו מכילה ערך סודי - כדאי לשמור עליה בצורה מאובטחת." + username: "שם משתמש" + username_instructions: "שם משתמש הבוט שמפרסם לערוץ. ברירת המחדל היא ‚מערכת’ כשזה נשאר ריק." + instructions: "מערכות חיצוניות יכולות להשתמש בהתליות כדי לפרסם הודעות לערוץ צ׳אט מסוים כמשתמש בוט דרך נקודת הגישה ‏/hooks/:key‎. המטען מורכב ממשתנה text (טקסט) יחיד שמוגבל ל־2000 תווים.

אנו תומכים גם במשתני text בעיצוב Slack, חילוץ קישורים ואזכורים לפי התקן https://api.slack.com/reference/surfaces/formatting, לשם כך יש להשתמש בנקודת הקצה ‎/hooks/:key/slack‎." + selection: + cancel: "ביטול" + quote_selection: "ציטוט בנושא" + copy: "העתקה" + move_selection_to_channel: "העברה לערוץ" + error: "אירעה שגיאה בהעברת הודעות הצ׳אט" + title: "העברת צ׳אט לנושא" + new_topic: + title: "העברה לנושא חדש" + instructions: + one: "פעולה זו תיצור נושא חדש ותמלא בו את הודעת הצ׳אט שבחרת." + two: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + many: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + other: "פעולה זו תיצור נושא חדש ותמלא בו את %{count} הודעות הצ׳אט שבחרת." + instructions_channel_archive: "פעולה זו תיצור נושא חדש ותעביר אליו את הודעות הערוץ כארכיון." + existing_topic: + title: "העברה לנושא קיים" + instructions: + one: "נא לבחור את הנושא אליו ברצונך להעביר את הודעת הצ׳אט הזו." + two: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + many: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + other: "נא לבחור את הנושא אליו ברצונך להעביר את %{count} הודעות הצ׳אט האלו." + instructions_channel_archive: "נא לבחור את הנושא אליו ברצונך להעביר את הודעות הערוץ כארכיון." + new_message: + title: "העברה להודעה חדשה" + instructions: + one: "פעולה זו תיצור הודעה חדשה ותמלא בה את הודעת הצ׳אט שבחרת." + two: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + many: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + other: "פעולה זו תיצור הודעה חדשה ותמלא בה את %{count} הודעות הצ׳אט שבחרת." + replying_indicator: + single_user: "מתבצעת הקלדה מצד %{username}" + multiple_users: "%{commaSeparatedUsernames} וגם %{lastUsername} מקלידים" + many_users: + one: "%{commaSeparatedUsernames} ועוד %{count} בנוסף מקלידים" + two: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + many: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + other: "%{commaSeparatedUsernames} ועוד %{count} נוספים מקלידים" + retention_reminders: + public: "היסטוריית הערוץ נשמרת למשך %{days} ימים." + dm: "היסטוריית הצ׳אט האישית נשמרת למשך %{days} ימים." + topic_button_title: "צ׳אט" + flags: + off_topic: "הודעה זו לא תואמת לדיון הנוכחי כפי שהוגדר בכותרת הערוץ וכנראה שצריך להעביר אותה." + inappropriate: "הודעה זו מכילה תוכן שאדם מן השורה עשוי להחשיב כפוגעני, נצלני או מפר את הכללים המנחים את הקהילה שלנו." + spam: "הודעה זו היא פרסומת או השחתה. היא אינה שימושית או רלוונטית לערוץ הנוכחי." + notify_user: "אשמח לדבר עם אותו גורם ישירות ובאופן אישי על ההודעה שנשלחה על ידי הגורם." + notify_moderators: "הודעה זו דורשת את התערבות הסגל מסיבות אחרות שאינן מופיעות לעיל." + flagging: + action: "סימון הודעה בדגל" + emoji_picker: + favorites: "נפוצים" + smileys_&_emotion: "חייכנים ורגשות" + objects: "חפצים" + people_&_body: "אנשים וגוף" + travel_&_places: "טיולים ומקומות" + animals_&_nature: "חיות וטבע" + food_&_drink: "מזון ושתייה" + activities: "פעילויות" + flags: "דגלים" + symbols: "סמלים" + search_placeholder: "חיפוש לפי שם וכינוי של האמוג׳י…" + no_results: "אין תוצאות" + notifications: + chat_invitation: "נשלחה אליך הזמנה להצטרף לערוץ צ׳אט" + chat_invitation_html: "הוזמנת להצטרף לערוץ צ׳אט על ידי %{username}" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'אוזכרת בערוץ „%{channel}”' + direct_html: 'אוזכרת בערוץ „%{channel}” על ידי %{username}' + other: 'נוסף אזכור של %{identifier} בערוץ „%{channel}”' + other_html: 'נוסף אזכור של %{identifier} בערוץ „%{channel}” על ידי %{username}' + direct_message_chat_mention: + direct: "אוזכרת בצ׳אט אישי" + direct_html: "אוזכרת בצ׳אט אישי על ידי %{username}" + other: "נוסף אזכור של %{identifier} בצ׳אט אישי" + other_html: "נוסף אזכור של ‎%{identifier} בצ׳אט אישי על ידי %{username}" + chat_message: "הודעת צ׳אט חדשה" + chat_quoted: "הודעת הצ׳אט שלך צוטטה על ידי %{username}" + titles: + chat_mention: "איזכור בצ׳אט" + chat_invitation: "הזמנה לצ׳אט" + chat_quoted: "הצ׳אט צוטט" + action_codes: + chat: + enabled: 'ההופעל על ידי%{who} ב־%{when}' + disabled: "הצ׳אט %{when} נסגר על ידי %{who}" + discourse_automation: + scriptables: + send_chat_message: + title: שליחת הודעת צ׳אט + fields: + chat_channel_id: + label: מזהה ערוץ צ׳אט + message: + label: הודעה + sender: + label: מוען + description: ברירת המחדל כמו המערכת + review: + transcript: + view: "הצגת תמלול הודעות קודמות" + types: + reviewable_chat_message: + title: "הודעת צ׳אט מסומנת" + flagged_by: "סומנה על ידי" + keyboard_shortcuts_help: + chat: + title: "צ׳אט" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} החלפת ערוץ" + open_quick_channel_selector: "%{shortcut} פתיחת בורר הערוצים המהיר" + open_insert_link_modal: "%{shortcut} הוספת קישור (עורך בלבד)" + composer_bold: "%{shortcut} מודגש (עורך בלבד)" + composer_italic: "%{shortcut} נטוי (עורך בלבד)" + composer_code: "%{shortcut} קוד (עורך בלבד)" + drawer_open: "%{shortcut} פתיחת מגירת הצ׳אט" + drawer_close: "%{shortcut} סגירת מגירת הצ׳אט" + topic_statuses: + chat: + help: "הצ׳אט מופעל בנושא הזה" + user: + allow_private_messages: "לאפשר למשתמשים אחרים לשלוח לי הודעות פרטיות והודעות ישירות בצ׳אט" + muted_users_instructions: "לדחות את כל ההתראות על הודעות אישיות והודעות ישירות בצ׳אט מהמשמתמשים האלה." + allowed_pm_users_instructions: "לאפשר רק הודעות אישיות או הודעות ישירות בצ׳אט מהמשמתמשים האלה." + allow_private_messages_from_specific_users: "לאפשר רק למשתמשים מסוימים לשלוח לי הודעות פרטיות או הודעות ישירות בצ׳אט" + ignored_users_instructions: "לדחות את כל הפוסטים, ההודעות, ההתראות, ההודעות אישיות וההודעות הישירות בצ׳אט מהמשמתמשים האלה." + user_menu: + no_chat_notifications_title: "עדיין אין לך התראות צ׳אט" + no_chat_notifications_body: > + תישלח אליך התראה בלוח הזה כאשר תישלח אליך הודעה ישירה או שיהיה @אזכור שלך בצ׳אט. תישלחנה התראות לדוא״ל שלך אם לא נכנסת מזה זמן רב.

לחיצה על הכותרת בראש כל ערוץ צ׳אט שהוא תעביר אותך להגדרת ההתראות שתישלחנה אליך באותו הערוץ. למידע נוסף, כדאי לבקר בהעדפות ההתראות שלך. + tabs: + chat_notifications: "התראות צ׳אט" + chat_notifications_with_unread: + one: "התראות צ׳אט - התראה %{count} שלא נקראה" + two: "התראות צ׳אט - %{count} התראות שלא נקראו" + many: "התראות צ׳אט - %{count} התראות שלא נקראו" + other: "התראות צ׳אט - %{count} התראות שלא נקראו" diff --git a/plugins/chat/config/locales/client.hr.yml b/plugins/chat/config/locales/client.hr.yml new file mode 100644 index 00000000000..24df4b375bf --- /dev/null +++ b/plugins/chat/config/locales/client.hr.yml @@ -0,0 +1,96 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Status chat kanala je promijenjen" + chat_channel_delete: "Chat kanal je izbrisan" + api: + scopes: + descriptions: + chat: + create_message: "Izradite chat poruku na određenom kanalu." + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Prikaz svih poruka" + already_enabled: "Chat je već omogućen na ovu temu. Molimo osvježite." + disabled_for_topic: "Chat je onemogućen na ovu temu." + bot: "bot" + create: "Stvorite" + cancel: "Otkazati" + cancel_reply: "Otkaži odgovor" + chat_channels: "Kanali" + move_to_channel: + title: "Premještanje poruka na kanal" + confirm_move: "Premjesti poruke" + channel_settings: + title: "Postavke kanala" + leave_channel: "Napusti kanal" + join: "Pridružite se" + channel_archive: + title: "Arhiviraj kanal" + instructions: "

Arhiviranje kanala stavlja ga u način rada samo za čitanje i premješta sve poruke s kanala u novu ili postojeću temu. Ne mogu se slati nove poruke, niti se postojeće poruke ne mogu uređivati ili brisati.

Jeste li sigurni da želite arhivirati %{channelTitle} kanal?

" + process_started: "Počeo je proces arhiviranja. Ovaj način će se uskoro zatvoriti, a vi ćete dobiti osobnu poruku kada je proces arhive završen." + retry: "Pokušaj ponovo" + channel_open: + title: "Otvori kanal" + instructions: "Ponovno otvara kanal, svi korisnici će moći slati poruke i uređivati svoje postojeće poruke." + channel_close: + title: "Zatvori kanal" + instructions: "Zatvaranje kanala onemogućuje korisnicima koji nisu zaposlenici da šalju nove poruke ili uređuju postojeće poruke. Jeste li sigurni da želite zatvoriti ovaj kanal?" + channel_delete: + title: "Izbriši kanal" + instructions: "

Briše %{name} kanal i povijest razgovora. Sve poruke i srodni podaci, kao što su reakcije i prijenosi, trajno će biti izbrisani. Ako želite sačuvati povijest kanala i raspada ga, možda želite arhivirati kanal umjesto.

Jeste li sigurni da želite trajno izbrisati kanal? Da biste potvrdili, upišite naziv kanala u okvir ispod.

" + confirm: "Razumijem posljedice, obriši kanal" + confirm_channel_name: "Unesite naziv kanala" + process_started: "Započeo je proces brisanja kanala. Ovaj modal će se uskoro zatvoriti, više nigdje nećete vidjeti izbrisani kanal." + channels_list_popup: + browse: "Pretražujte kanale" + click_to_join: "Kliknite ovdje da biste vidjeli dostupne kanale." + close: "Zatvoriti" + collapse: "Sažmi ladicu za chat" + confirm_flag: "Jeste li sigurni da želite označiti poruku korisnika %{username}?" + deleted: "Poruka je izbrisana. [pogledaj]" + delete: "Izbriši" + edited: "uredio" + empty_state: + direct_message: "Također možete započeti osobni razgovor s jednim ili više korisnika." + enable: "Omogući chat" + flag: "Prijavi" + flagged: "Ova poruka je označena za pregled" + invalid_access: "Nemate pristup za pregled ovog kanala za chat" + invitation_notification: "%{username} vas je pozvao da se pridružite chat kanalu" + in_reply_to: "U odgovoru na" + heading: "Čet" + join: "Pridružite se" + new_messages: "nove poruke" + mention_warning: + cannot_see: + one: "%{usernames} ne može pristupiti ovom kanalu i nije obaviješten." + few: "%{usernames} ne mogu pristupiti ovom kanalu i nisu obaviješteni." + other: "%{usernames} ne mogu pristupiti ovom kanalu i nisu obaviješteni." + dismiss: "odbaciti" + invitations_sent: + one: "Poziv poslan" + few: "Pozivnice poslane" + other: "Pozivnice poslane" + invite: "Pozovite na kanal" + without_membership: + one: "%{usernames} se nije pridružio ovom kanalu." + few: "%{usernames} se nisu pridružili ovom kanalu." + other: "%{usernames} se nisu pridružili ovom kanalu." + aria_roles: + header: "Zaglavlje chata" + composer: "Skladatelj chata" + browse: + title: Kanali + notifications: + chat_invitation_html: "%{username} vas je pozvao da se pridružite chat kanalu" diff --git a/plugins/chat/config/locales/client.hu.yml b/plugins/chat/config/locales/client.hu.yml new file mode 100644 index 00000000000..6c08e51cd81 --- /dev/null +++ b/plugins/chat/config/locales/client.hu.yml @@ -0,0 +1,398 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "A csevegőcsatorna állapota megváltozott" + chat_channel_delete: "Csevegőcsatorna törölve" + api: + scopes: + descriptions: + chat: + create_message: "Csevegőüzenet létrehozása egy megadott csatornán." + about: + chat_messages_count: "Csevegőüzenetek" + chat_channels_count: "Csevegőcsatornák" + chat_users_count: "Csevegőfelhasználók" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Összes üzenet megjelenítése" + already_enabled: "A csevegés már engedélyezve van ebben a témában. Frissítsen." + disabled_for_topic: "A csevegés le van tiltva ebben a témában." + bot: "bot" + create: "Létrehozás" + cancel: "Mégse" + cancel_reply: "Válasz elvetése" + chat_channels: "Csatornák" + browse_all_channels: "Összes csatorna böngészése" + move_to_channel: + title: "Üzenetek áthelyezése a csatornába" + instructions: + one: "Ön 1 üzenetet helyet át. Válasszon ki egy célcsatornát. A(z) %{channelTitle} csatornán egy helykitöltő üzenet jön létre, amely jelzi, hogy ez az üzenet áthelyezésre került." + other: "Ön %{count} üzenetet helyet át. Válasszon ki egy célcsatornát. A(z) %{channelTitle} csatornán egy helykitöltő üzenet jön létre, amely jelzi, hogy ez az üzenet áthelyezésre került." + confirm_move: "Üzenetek áthelyezése" + channel_settings: + title: "Csatornabeállítások" + edit: "Szerkesztés" + add: "Hozzáadás" + close_channel: "Csatorna bezárása" + open_channel: "Csatorna megnyitása" + archive_channel: "Csatorna archiválása" + delete_channel: "Csatorna törlése" + join_channel: "Csatlakozás a csatornához" + leave_channel: "Csatorna elhagyása" + join: "Csatlakozás" + leave: "Elhagyás" + channel_archive: + title: "Csatorna archiválása" + instructions: "

Egy csatorna archiválása írásvédett módba helyezi azt, és a csatorna összes üzenetét egy új vagy meglévő témába helyezi át. Új üzeneteket nem lehet küldeni, és a meglévő üzeneteket nem lehet szerkeszteni vagy törölni.

Biztos, hogy archiválja a(z) %{channelTitle} csatornát?

" + process_started: "Az archiválási folyamat megkezdődött. Ez az ablak rövidesen bezárul, és az archiválási folyamat befejeztével személyes üzenetet kap." + retry: "Újra" + channel_open: + title: "Csatorna megnyitása" + instructions: "Újranyitja a csatornát, az összes felhasználó fog tudni üzeneteket küldeni és szerkeszteni a meglévőket." + channel_close: + title: "Csatorna bezárása" + instructions: "A csatorna lezárása megakadályozza, hogy a nem stábtagok új üzeneteket küldjenek vagy szerkesszék a meglévő üzeneteket. Biztos, hogy be akarja zárni ezt a csatornát?" + channel_delete: + title: "Csatorna törlése" + instructions: "

Törli a(z) %{name} csatornát és a csevegési előzményeket. Minden üzenet és a kapcsolódó adat, például a reakciók és a feltöltések véglegesen törlődnek. Ha meg szeretné őrizni a csatorna előzményeit, de meg akarja szüntetni, akkor inkább archiválja a csatornát.

Biztos, hogy véglegesen törli a csatornát? A megerősítéshez írja be a csatorna nevét az alábbi mezőbe.

" + confirm: "Megértem a következményeket, a csatorna törlése" + confirm_channel_name: "Adja meg a csatorna nevét" + process_started: "A csatorna törlése megkezdődött. Ez a kérdésablak hamarosan bezárul, és már nem fogja látni a törölt csatornát." + channels_list_popup: + browse: "Csatornák böngészése" + click_to_join: "Kattintson ide az elérhető csatornák megtekintéséhez." + close: "Bezárás" + collapse: "Csevegőfiók összecsukása" + confirm_flag: "Biztos, hogy jelenti %{username} üzenetét?" + deleted: "Egy üzenet törölve lett. [megtekintés]" + hidden: "Egy üzenet el lett rejtve. [megtekintés]" + delete: "Törlés" + edited: "szerkesztve" + muted: "némítva" + joined: "csatlakozott" + empty_state: + direct_message_cta: "Személyes csevegés indítása" + direct_message: "Személyes csevegést is indíthat egy vagy több felhasználóval." + title: "Nem található csatorna" + email_frequency: + description: "Csak akkor küldünk e-mailt, ha az elmúlt 15 percben nem láttuk." + never: "Soha" + title: "E-mail értesítések" + when_away: "Csak ha távol van" + enable: "Csevegés engedélyezése" + flag: "Jelentés" + flagged: "Ezt az üzenetet felülvizsgálatra jelentették" + invalid_access: "Nincs hozzáférése ennek a csevegőcsatornának a megtekintéséhez" + invitation_notification: "%{username} meghvta Önt, hogy csatlakozzon egy csevegőcsatornához" + in_reply_to: "Válaszul erre:" + heading: "Csevegés" + join: "Csatlakozás" + new_messages: "új üzenetek" + mention_warning: + cannot_see: + one: "%{usernames} nem fér hozzá ehhez a csatornához, és nem lett értesítve." + other: "%{usernames} nem fér hozzá ehhez a csatornához, és nem lettek értesítve." + dismiss: "eltüntetés" + invitations_sent: + one: "Meghívó elküldve" + other: "Meghívók elküldve" + invite: "Meghívás a csatornára" + without_membership: + one: "%{usernames} nem csatlakozott ehhez a csatornához." + other: "%{usernames} nem csatlakozott ehhez a csatornához." + aria_roles: + header: "Csevegés fejléce" + composer: "Csevegés szerkesztője" + channels_list: "Csevegőcsatornák" + no_public_channels: "Nem csatlakozott egyetlen csatornához sem." + only_chat_push_notifications: + title: "Csak csevegési leküldéses értesítések küldése" + description: "Az összes nem csevegési leküldéses értesítés elküldésének letiltása" + ignore_channel_wide_mention: + title: "A csatornaszintű említések figyelmen kívül hagyása" + description: "Ne küldjön értesítést a csatornaszintű említésekről (@here és @all)" + open: "Csevegés megnyitása" + open_full_page: "Teljes képernyős csevegés megnyitása" + close_full_page: "Teljes képernyős csevegés bezárása" + open_message: "Üzenet megnyitása a csevegésben" + placeholder_self: "Jegyezzen le valamit" + placeholder_others: "Csevegés vele: %{messageRecipient}" + placeholder_new_message_disallowed: "A csatorna „%{status}”, jelenleg nem küldhet új üzeneteket." + placeholder_silenced: "Jelenleg nem küldhet üzeneteket." + placeholder_start_conversation: 'Beszélgetés kezdése a következőkkel: %{usernames}' + remove_upload: "Fájl eltávolítása" + react: "Reagálás emodzsival" + reply: "Válasz" + edit: "Szerkesztés" + copy_link: "Hivatkozás másolása" + rebake_message: "HTML újraépítése" + retry_staged_message: + title: "Hálózati hiba" + action: "Újraküldi?" + unreliable_network: "A hálózat nem megbízható, az üzenetek küldése és a piszkozat mentése lehet, hogy nem működik." + bookmark_message: "Könyvjelző" + bookmark_message_edit: "Könyvjelző szerkesztése" + restore: "Törölt üzenet helyreállítása" + save: "Mentés" + select: "Válasszon" + silence: "Felhasználó némítása" + return_to_list: "Vissza a csatornákhoz" + scroll_to_bottom: "Görgetés lefelé" + scroll_to_new_messages: "Új üzenetek megtekintése" + sound: + title: "Asztali csevegőértesítés hangja" + sounds: + none: "Nincs" + bell: "Harang" + ding: "Csengettyű" + title: "csevegés" + title_capitalized: "Csevegés" + upload: "Fájl csatolása" + uploaded_files: + one: "%{count} fájl" + other: "%{count} fájl" + you_flagged: "Ön jelentette ezt az üzenetet" + exit: "vissza" + channel_status: + read_only_header: "A csatorna írásvédett" + read_only: "Csak olvasható" + archived_header: "A csatorna archiválva van" + archived: "Archivált" + archive_failed: "A csatorna archiválása nem sikerült.%{completed}/%{total} üzenet archiválva lett a céltémában . Nyomja meg az Újra gombot az archiválás befejezéséhez." + archive_completed: "Lásd az archív témát" + closed_header: "A csatorna zárolt" + closed: "Zárolt" + open_header: "A csatorna nyitott" + open: "Nyitott" + browse: + title: Csatornák + filter_all: Összes + filter_open: Nyitott + filter_closed: Zárolt + filter_archived: Archivált + filter_input_placeholder: Csatorna keresése név szerint + chat_message_separator: + today: Ma + yesterday: Tegnap + members_view: + filter_placeholder: Tagok keresése + about_view: + associated_topic: Kapcsolódó téma + associated_category: Kapcsolódó kategória + title: Cím + description: Leírás + channel_info: + back_to_all_channels: "Összes csatorna" + back_to_channel: "Vissza" + tabs: + about: Névjegy + members: Tagok + settings: Beállítások + channel_edit_title_modal: + title: Cím szerkesztése + input_placeholder: Cím hozzáadása + description: Adjon egy rövid, leíró címet a csatornájának + channel_edit_description_modal: + title: Leírás szerkesztése + input_placeholder: Leírás hozzáadása + description: Mondja el az embereknek, hogy miről szól ez a csatorna + direct_message_creator: + title: Új üzenet + prefix: "Címzett:" + no_results: Nincs találat + selected_user_title: "%{username} kijelölésének megszüntetése" + channel_selector: + title: "Ugrás a csatornára" + no_channels: "Egyetlen csatorna sem felel meg a keresésnek" + channel: + no_memberships: Ennek a csatornának nincsenek tagjai + no_memberships_found: Nem találhatók tagok + memberships_count: + one: 1 tag + other: "%{count} tag" + create_channel: + auto_join_users: + public_category_warning: "A(z) %{category} egy nyilvános kategória. Automatikusan hozzáadja az összes nemrég aktív felhasználót ehhez a csatornához?" + warning_groups: + one: '%{members_count} felhasználó automatikus hozzáadása a(z) %{group_1} csoportból?' + other: '%{members_count} felhasználó automatikus hozzáadása a(z) %{group_1} és %{group_2} csoportokból?' + warning_multiple_groups: Automatikusan hozzáadja a(z) %{group_1} csoport %{members_count} felhasználóját, és további %{count} felhasználót? + choose_category: + label: "Válasszon kategóriát" + none: "válasszon egyet…" + default_hint: Kezelje a hozzáférést a(z) %{category} biztonsági beállításainak felkeresésével + create: "Csatorna létrehozása" + description: "Leírás (nem kötelező)" + name: "Csatorna neve" + type: "Típus" + types: + category: "Kategória" + topic: "Téma" + reviewable: + type: "Csevegőüzenet" + reactions: + only_you: "Ezzel reagált: :%{emoji}:" + and_others: "Ön, %{usernames} ezzel reagált: :%{emoji}:" + only_others: "%{usernames} ezzel reagált: :%{emoji}:" + others_and_more: "%{usernames} és még %{more} valaki ezzel reagált: :%{emoji}:" + you_others_and_more: "Ön, %{usernames} és még %{more} valaki ezzel reagált: :%{emoji}:" + composer: + toggle_toolbar: "Eszköztár be/ki" + italic_text: "kiemelt szöveg" + bold_text: "erős szöveg" + code_text: "kódszöveg" + quote: + copy_success: "Csevegési idézet a vágólapra másolva" + notification_levels: + never: "Soha" + mention: "Csak megemlítésnél" + always: "Összes tevékenységnél" + settings: + enable_auto_join_users: "Az összes nemrég aktív felhasználó automatikus hozzáadása" + disable_auto_join_users: "A felhasználók automatikus hozzáadásának leállítása" + desktop_notification_level: "Asztali értesítések" + follow: "Csatlakozás" + followed: "Csatlakozott" + mobile_notification_level: "Mobilos leküldéses értesítések" + mute: "Csatorna némítása" + muted_on: "Be" + muted_off: "Ki" + notifications: "Értesítések" + preview: "Előnézet" + save: "Mentés" + saved: "Mentve" + unfollow: "Elhagyás" + admin: + title: "Csevegés" + direct_messages: + title: "Személyes csevegés" + new: "Új személyes csevegés" + create: "Ugrás" + leave: "E személyes csevegés elhagyása" + incoming_webhooks: + back: "Vissza" + channel_placeholder: "Válasszon csatornát" + confirm_destroy: "Biztos, hogy törli ezt a bejövő webhookot? Ezt nem lehet visszavonni." + current_emoji: "Jelenlegi emodzsi" + description: "Leírás" + delete: "Törlés" + emoji: "Emodzsi" + emoji_instructions: "A rendszerben használt profilképe lesz használva, ha az emodzsi üresen marad." + name: "Név" + name_placeholder: "név…" + new: "Új bejövő webhook" + none: "Nincs meglévő bejövő webhoook létrehozva." + no_emoji: "Nincs emodzsi kiválasztva" + post_to: "Közzététel ide:" + reset_emoji: "Emodzsi visszaállítása" + save: "Mentés" + edit: "Szerkesztés" + select_emoji: "Válasszon emodzsit" + system: "rendszer" + title: "Bejövő webhookok" + url: "URL" + username: "Felhasználónév" + username_instructions: "A csatornán közzétevő bot felhasználóneve. Ha üresen marad, akkor alapértelmezés szerint „rendszer”." + selection: + cancel: "Mégse" + quote_selection: "Idézet a témában" + copy: "Másolás" + move_selection_to_channel: "Áthelyezés csatornába" + error: "Hiba történt a csevegőüzenetek áthelyezésekor" + title: "Csevegés áthelyezése a témához" + new_topic: + title: "Áthelyezés új témához" + instructions: + one: "Arra készül, hogy új témát hozzon létre, és feltöltse azt a kiválasztott csevegőüzenettel." + other: "Arra készül, hogy új témát hozzon létre, és feltöltse azt %{count} kiválasztott csevegőüzenettel." + instructions_channel_archive: "Egy új témát fog létrehozni, és archiválni fogja a csatornaüzeneteket." + existing_topic: + title: "Áthelyezés meglévő témához" + instructions: + one: "Válassza ki azt a témát, amelyhez áthelyezi a csevegőüzenetet." + other: "Válassza ki azt a témát, amelyhez áthelyez %{count} csevegőüzenetet." + instructions_channel_archive: "Válassza ki azt a témát, amelybe a csatornaüzeneteket archiválná." + new_message: + title: "Ugrás az új üzenethez" + instructions: + one: "Arra készül, hogy új üzenet hozzon létre, és feltöltse azt a kiválasztott csevegőüzenettel." + other: "Arra készül, hogy új üzenetet hozzon létre, és feltöltse azt %{count} kiválasztott csevegőüzenettel." + replying_indicator: + single_user: "%{username} gépel" + multiple_users: "%{commaSeparatedUsernames} és %{lastUsername} gépel" + many_users: + one: "%{commaSeparatedUsernames} és még %{count} valaki gépel" + other: "%{commaSeparatedUsernames} és még %{count} valaki gépel" + retention_reminders: + public: "A csatorna előzményei %{days} napig maradnak meg." + dm: "A személyes csevegési előzményei %{days} napig maradnak meg." + topic_button_title: "Csevegés" + emoji_picker: + no_results: "Nincs találat" + notifications: + chat_invitation: "meghívta Önt, hogy csatlakozzon egy csevegőcsatornához" + chat_invitation_html: "%{username} meghvta Önt, hogy csatlakozzon egy csevegőcsatornához" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'megemlítette Önt a következő csatornán: „%{channel}”' + direct_html: '%{username} megemlítette Önt a következő csatornán: „%{channel}”' + other: 'megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + other_html: '%{username} megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + direct_message_chat_mention: + direct: "megemlítette Önt egy személyes csevegésben" + direct_html: "%{username} megemlítette Önt egy személyes csevegésben" + other: "megemlítette %{identifier} felhasználót egy személyes csevegésben" + other_html: "%{username} megemlítette %{identifier} felhasználót egy személyes csevegésben" + chat_message: "Új csevegőüzenet" + chat_quoted: "%{username} idézte a csevegési üzenetet" + titles: + chat_mention: "Csevegési megemlítés" + chat_invitation: "Csevegési meghívó" + chat_quoted: "Csevegés idézve" + action_codes: + chat: + enabled: '%{who} engedélyezte a %{when}' + disabled: "%{who} lezárta a csevegést %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Csevegőüzenet küldése + fields: + chat_channel_id: + label: Csevegőcsatorna azonosítója + message: + label: Üzenet + sender: + label: Feladó + description: Alapértelmezés szerint a rendszer + review: + types: + reviewable_chat_message: + title: "Jelentett csevegőüzenet" + flagged_by: "Jelentette:" + keyboard_shortcuts_help: + chat: + title: "Csevegés" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Csatorna váltása" + open_quick_channel_selector: "%{shortcut} Gyors csatornaválasztó megnyitása" + open_insert_link_modal: "%{shortcut} Hiperhivatkozás beillesztése (csak a szerkesztőben)" + composer_bold: "%{shortcut} Félkövér (csak a szerkesztőben)" + composer_italic: "%{shortcut} Dőlt (csak a szerkesztőben)" + composer_code: "%{shortcut} Kód (csak a szerkesztőben)" + drawer_open: "%{shortcut} Csevegési fiók megnyitása" + drawer_close: "%{shortcut} Csevegési fiók bezárása" + topic_statuses: + chat: + help: "A csevegés engedélyezett ebben a témában" diff --git a/plugins/chat/config/locales/client.hy.yml b/plugins/chat/config/locales/client.hy.yml new file mode 100644 index 00000000000..cb18f64d356 --- /dev/null +++ b/plugins/chat/config/locales/client.hy.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: diff --git a/plugins/chat/config/locales/client.id.yml b/plugins/chat/config/locales/client.id.yml new file mode 100644 index 00000000000..596e36b2e13 --- /dev/null +++ b/plugins/chat/config/locales/client.id.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: diff --git a/plugins/chat/config/locales/client.it.yml b/plugins/chat/config/locales/client.it.yml new file mode 100644 index 00000000000..b6de5b753ce --- /dev/null +++ b/plugins/chat/config/locales/client.it.yml @@ -0,0 +1,228 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostra tutti i messaggi" + already_enabled: "La chat è già abilitata su questo argomento. Prova ad aggiornare." + disabled_for_topic: "La chat è disabilitata su questo argomento." + bot: "bot" + create: "Crea" + cancel: "Annulla" + cancel_reply: "Annulla risposta" + chat_channels: "Canali" + channel_settings: + edit: "Modifica" + join_channel: "Partecipa al canale" + leave_channel: "Lascia il canale" + join: "Partecipa" + leave: "Esci" + channels_list_popup: + browse: "Sfoglia i canali" + click_to_join: "Fai clic qui per visualizzare i canali disponibili." + close: "Chiudi" + collapse: "Comprimi il cassetto della chat" + confirm_flag: "Vuoi segnalare il messaggio di %{username}?" + deleted: "Un messaggio è stato eliminato. [visualizza]" + delete: "Elimina" + edited: "modificato" + empty_state: + direct_message: "Puoi anche avviare una chat personale con uno o più utenti." + email_frequency: + never: "Mai" + enable: "Abilita chat" + flag: "Segnala" + flagged: "Questo messaggio è stato segnalato per la revisione" + invalid_access: "Non hai accesso alla visualizzazione di questo canale chat" + in_reply_to: "In risposta a" + heading: "Chat" + join: "Partecipa" + new_messages: "nuovi messaggi" + mention_warning: + cannot_see: + one: "%{usernames} non può accedere a questo canale e non è stato avvisato." + other: "%{usernames} non possono accedere a questo canale e non sono stati avvisati." + dismiss: "ignora" + invitations_sent: + one: "Invito inviato" + other: "Inviti inviati" + invite: "Invita al canale" + without_membership: + one: "%{usernames} non ha partecipato a questo canale." + other: "%{usernames} non hanno partecipato a questo canale." + no_public_channels: "Non hai partecipato a nessun canale." + only_chat_push_notifications: + title: "Invia solo le notifiche push della chat" + description: "Blocca l'invio di tutte le notifiche push non relative alla chat" + open: "Apri chat" + open_full_page: "Apri chat a schermo intero" + open_message: "Apri messaggio in chat" + placeholder_self: "Scrivi qualche annotazione" + placeholder_others: "Chatta con %{messageRecipient}" + remove_upload: "Rimuovi file" + react: "Reagisci con delle emoji" + reply: "Rispondi" + edit: "Modifica" + copy_link: "Copia link" + rebake_message: "Ricompila HTML" + restore: "Ripristina messaggio eliminato" + save: "Salva" + select: "Seleziona" + scroll_to_bottom: "Scorri fino in fondo" + sound: + title: "Suono di notifica della chat desktop" + sounds: + none: "Nessuno" + bell: "Campanello" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Allega un file" + uploaded_files: + one: "%{count} file" + other: "%{count} file" + you_flagged: "Hai segnalato questo messaggio" + exit: "indietro" + browse: + title: Canali + about_view: + description: Descrizione + channel_info: + back_to_channel: "Indietro" + channel_selector: + title: "Vai al canale" + no_channels: "Nessun canale corrisponde alla tua ricerca" + create_channel: + choose_category: + label: "Scegli una categoria" + none: "selezionane una..." + default_hint: Gestisci l'accesso visitando le impostazioni di sicurezza di %{category} + create: "Crea canale" + description: "Descrizione (facoltativa)" + name: "Nome del canale" + type: "Tipo" + types: + category: "Categoria" + topic: "Argomento" + reviewable: + type: "Messaggio di chat" + reactions: + only_you: "Hai reagito con :%{emoji}:" + and_others: "Tu, %{usernames} avete reagito con :%{emoji}:" + only_others: "%{usernames} ha reagito con :%{emoji}:" + others_and_more: "%{usernames} e altri %{more} hanno reagito con :%{emoji}:" + you_others_and_more: "Tu, %{usernames} e altri %{more} avete reagito con :%{emoji}:" + composer: + toggle_toolbar: "Attiva barra degli strumenti" + notification_levels: + never: "Mai" + mention: "Solo per le menzioni" + always: "Per tutte le attività" + settings: + desktop_notification_level: "Notifiche sul desktop" + follow: "Partecipa" + mobile_notification_level: "Notifiche push su dispositivi mobili" + mute: "Silenzia canale" + muted_on: "On" + muted_off: "Off" + notifications: "Notifiche" + preview: "Anteprima" + save: "Salva" + saved: "Salvato" + unfollow: "Esci" + admin: + title: "Chat" + direct_messages: + title: "Chat personale" + new: "Nuova chat personale" + create: "Vai" + leave: "Lascia questa chat personale" + incoming_webhooks: + back: "Indietro" + channel_placeholder: "Seleziona un canale" + confirm_destroy: "Vuoi eliminare questo webhook in ingresso? L'operazione non può essere annullata." + current_emoji: "Emoji attuale" + description: "Descrizione" + delete: "Elimina" + emoji: "Emoji" + emoji_instructions: "L'avatar di sistema verrà utilizzato se l'emoji è lasciata vuota." + name: "Nome" + name_placeholder: "nome..." + new: "Nuovo webhook in ingresso" + none: "Non sono stati creati webhook in ingresso." + no_emoji: "Nessuna emoji selezionata" + post_to: "Pubblica in" + reset_emoji: "Reimposta emoji" + save: "Salva" + edit: "Modifica" + select_emoji: "Scegli emoji" + system: "sistema" + title: "Webhook in ingresso" + url: "URL" + username: "Nome utente" + username_instructions: "Nome utente del bot che pubblica sul canale. Il valore predefinito è 'system' se l'impostazione è lasciata vuota." + selection: + cancel: "Annulla" + error: "Si è verificato un errore durante lo spostamento dei messaggi di chat" + title: "Sposta la chat nell'argomento" + new_topic: + title: "Sposta nel nuovo argomento" + instructions: + one: "Stai per creare un nuovo argomento, inserendovi il messaggio di chat che hai selezionato." + other: "Stai per creare un nuovo argomento, inserendovi i %{count} messaggi di chat che hai selezionato." + existing_topic: + title: "Sposta in argomento esistente" + instructions: + one: "Scegli l'argomento in cui intendi spostare il messaggio di chat." + other: "Scegli l'argomento in cui intendi spostare questi %{count} messaggi di chat." + new_message: + title: "Sposta in un nuovo messaggio" + instructions: + one: "Stai per creare un nuovo messaggio, inserendovi il messaggio di chat che hai selezionato." + other: "Stai per creare un nuovo messaggio, inserendovi i %{count} messaggi di chat che hai selezionato." + replying_indicator: + single_user: "%{username} sta scrivendo" + multiple_users: "%{commaSeparatedUsernames} e %{lastUsername} stanno scrivendo" + many_users: + one: "%{commaSeparatedUsernames} e %{count} altro stanno scrivendo" + other: "%{commaSeparatedUsernames} e altri %{count} stanno scrivendo" + retention_reminders: + public: "La cronologia del canale è conservata per %{days} giorni." + dm: "La cronologia della chat personale è conservata per %{days} giorni." + topic_button_title: "Chat" + notifications: + popup: + chat_message: "Nuovo messaggio di chat" + titles: + chat_mention: "Menzione in chat" + chat_invitation: "Invito alla chat" + action_codes: + chat: + enabled: '%{who} ha abilitato la %{when}' + disabled: "%{who} ha chiuso la chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Invia messaggio di chat + fields: + chat_channel_id: + label: ID canale chat + message: + label: Messaggio + sender: + label: Mittente + description: Torna ai valori predefiniti di sistema + review: + types: + reviewable_chat_message: + title: "Messaggio di chat segnalato" + flagged_by: "Segnalato da" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.ja.yml b/plugins/chat/config/locales/client.ja.yml new file mode 100644 index 00000000000..841ea2e184f --- /dev/null +++ b/plugins/chat/config/locales/client.ja.yml @@ -0,0 +1,220 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "すべてのメッセージを表示中" + already_enabled: "このトピックのチャットはすでに有効になっています。再読み込みしてください。" + disabled_for_topic: "このトピックのチャットは無効になっています。" + bot: "ボット" + create: "作成" + cancel: "キャンセル" + cancel_reply: "返信をキャンセルする" + chat_channels: "チャンネル" + channel_settings: + edit: "編集" + join_channel: "チャンネルに参加する" + leave_channel: "チャンネルから退出する" + join: "参加" + leave: "退出" + channels_list_popup: + browse: "チャンネルを閲覧する" + click_to_join: "利用可能なチャンネルを表示するにはここをクリックします。" + close: "閉じる" + collapse: "チャットドロワーを折りたたむ" + confirm_flag: "%{username} のメッセージを通報してよろしいですか?" + deleted: "メッセージは削除されました。[view]" + delete: "削除" + edited: "編集済み" + empty_state: + direct_message: "1 人または複数のユーザーとパーソナルチャットを開始することもできます。" + email_frequency: + never: "なし" + enable: "チャットを有効にする" + flag: "通報する" + flagged: "このメッセージはレビュー目的で通報されました" + invalid_access: "このチャットチャンネルを表示するためのアクセス権がありません" + in_reply_to: "返信先" + heading: "チャット" + join: "参加" + new_messages: "新しいメッセージ" + mention_warning: + cannot_see: + other: "%{usernames} はこのチャンネルにアクセスできないため通知されませんでした。" + dismiss: "閉じる" + invitations_sent: + other: "招待状を送信しました" + invite: "チャンネルに招待する" + without_membership: + other: "%{usernames} はこのチャンネルに参加していません。" + no_public_channels: "どのチャンネルにも参加していません。" + only_chat_push_notifications: + title: "チャットのプッシュ通知のみ送信する" + description: "チャット以外のすべてのプッシュ通知の送信をブロックします" + open: "チャットを開く" + open_full_page: "全画面チャットを開く" + open_message: "メッセージをチャットで開く" + placeholder_self: "メモを書き留める" + placeholder_others: "%{messageRecipient} とチャット" + remove_upload: "ファイルを削除する" + react: "絵文字でリアクション" + reply: "返信" + edit: "編集" + copy_link: "リンクをコピーする" + rebake_message: "HTML を再構築" + restore: "削除されたメッセージを復元する" + save: "保存" + select: "選択" + scroll_to_bottom: "一番下にスクロール" + sound: + title: "デスクトップチャットの通知音" + sounds: + none: "なし" + bell: "ベル" + ding: "ゴーン" + title: "チャット" + title_capitalized: "チャット" + upload: "ファイルを添付する" + uploaded_files: + other: "%{count} 個のファイル" + you_flagged: "このメッセージを通報しました" + exit: "戻る" + browse: + title: チャンネル + about_view: + description: 説明 + channel_info: + back_to_channel: "戻る" + channel_selector: + title: "チャンネルにジャンプ" + no_channels: "検索に一致するチャンネルはありません" + create_channel: + choose_category: + label: "カテゴリを選択する" + none: "1 つ選択してください..." + default_hint: %{category} のセキュリティ設定に移動し、アクセス権を管理します + create: "チャンネルを作成" + description: "説明(オプション)" + name: "チャンネル名" + type: "タイプ" + types: + category: "カテゴリ" + topic: "トピック" + reviewable: + type: "チャットメッセージ" + reactions: + only_you: ":%{emoji}: でリアクションしました" + and_others: "あなたと %{usernames} が :%{emoji}: でリアクションしました" + only_others: "%{usernames} が :%{emoji}: でリアクションしました" + others_and_more: "%{usernames} と他 %{more} 人が :%{emoji}: でリアクションしました" + you_others_and_more: "あなた、%{usernames} と他 %{more} 人が :%{emoji}: でリアクションしました" + composer: + toggle_toolbar: "ツールバーの切り替え" + notification_levels: + never: "なし" + mention: "メンションのみ" + always: "すべてのアクティビティ" + settings: + desktop_notification_level: "デスクトップ通知" + follow: "参加" + mobile_notification_level: "モバイルプッシュ通知" + mute: "チャンネルをミュート" + muted_on: "オン" + muted_off: "オフ" + notifications: "通知" + preview: "プレビュー" + save: "保存" + saved: "保存しました" + unfollow: "退出" + admin: + title: "チャット" + direct_messages: + title: "パーソナルチャット" + new: "新しいパーソナルチャット" + create: "開始" + leave: "このパーソナルチャットから退出する" + incoming_webhooks: + back: "戻る" + channel_placeholder: "チャンネルを選択する" + confirm_destroy: "この着信 Webhook を削除してもよろしいですか?この操作は元に戻せません。" + current_emoji: "現在の絵文字" + description: "説明" + delete: "削除" + emoji: "絵文字" + emoji_instructions: "絵文字を空白のままにすると、システムアバターが使用されます。" + name: "名前" + name_placeholder: "名前..." + new: "新しい着信 Webhook" + none: "既存の着信 Webhook は作成されていません。" + no_emoji: "絵文字が選択されていません" + post_to: "投稿先" + reset_emoji: "絵文字をリセット" + save: "保存" + edit: "編集" + select_emoji: "絵文字を選択" + system: "システム" + title: "着信 Webhook" + url: "URL" + username: "ユーザー名" + username_instructions: "チャンネルに投稿するボットのユーザー名。空白のままにすると、デフォルトで「システム」になります。" + selection: + cancel: "キャンセル" + error: "チャットメッセージを移動中にエラーが発生しました" + title: "チャットをトピックに移動" + new_topic: + title: "新しいトピックに移動" + instructions: + other: "新しいトピックを作成し、選択した %{count} 件のチャットメッセージを挿入しようとしています。" + existing_topic: + title: "既存のトピックに移動" + instructions: + other: "それらの %{count} 件のチャットメッセージを移動するトピックを選択してください。" + new_message: + title: "新しいメッセージに移動" + instructions: + other: "新しいメッセージを作成し、選択した %{count} 件のチャットメッセージを挿入しようとしています。" + replying_indicator: + single_user: "%{username} が入力中です" + multiple_users: "%{commaSeparatedUsernames} と %{lastUsername} が入力中です" + many_users: + other: "%{commaSeparatedUsernames} と他 %{count} 人が入力中です" + retention_reminders: + public: "チャンネル履歴は %{days} 日間保持されます。" + dm: "パーソナルチャット履歴は %{days} 日間保持されます。" + topic_button_title: "チャット" + notifications: + popup: + chat_message: "新しいチャットメッセージ" + titles: + chat_mention: "チャットのメンション" + chat_invitation: "チャットの招待状" + action_codes: + chat: + enabled: '%{who} がを有効にしました: %{when}' + disabled: "%{who} がチャットをクローズしました: %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: チャットメッセージを送信する + fields: + chat_channel_id: + label: チャットチャンネル ID + message: + label: メッセージ + sender: + label: 送信者 + description: デフォルトはシステムです + review: + types: + reviewable_chat_message: + title: "通報されたチャットメッセージ" + flagged_by: "通報者" + keyboard_shortcuts_help: + chat: + title: "チャット" diff --git a/plugins/chat/config/locales/client.ko.yml b/plugins/chat/config/locales/client.ko.yml new file mode 100644 index 00000000000..18dd77fd3ec --- /dev/null +++ b/plugins/chat/config/locales/client.ko.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: diff --git a/plugins/chat/config/locales/client.lt.yml b/plugins/chat/config/locales/client.lt.yml new file mode 100644 index 00000000000..16bb19758dc --- /dev/null +++ b/plugins/chat/config/locales/client.lt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: diff --git a/plugins/chat/config/locales/client.lv.yml b/plugins/chat/config/locales/client.lv.yml new file mode 100644 index 00000000000..59e0ef6f4ed --- /dev/null +++ b/plugins/chat/config/locales/client.lv.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: diff --git a/plugins/chat/config/locales/client.nb_NO.yml b/plugins/chat/config/locales/client.nb_NO.yml new file mode 100644 index 00000000000..2e2224d1472 --- /dev/null +++ b/plugins/chat/config/locales/client.nb_NO.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: diff --git a/plugins/chat/config/locales/client.nl.yml b/plugins/chat/config/locales/client.nl.yml new file mode 100644 index 00000000000..2096bfe4865 --- /dev/null +++ b/plugins/chat/config/locales/client.nl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: diff --git a/plugins/chat/config/locales/client.pl_PL.yml b/plugins/chat/config/locales/client.pl_PL.yml new file mode 100644 index 00000000000..aa0c0c7c16c --- /dev/null +++ b/plugins/chat/config/locales/client.pl_PL.yml @@ -0,0 +1,289 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Zmieniono status kanału czatu" + chat_channel_delete: "Kanał czatu usunięty" + about: + chat_messages_count: "Wiadomości czatu" + chat_channels_count: "Kanały czatu" + chat_users_count: "Użytkownicy czatu" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Pokazuje wszystkie wiadomości" + already_enabled: "Czat jest już włączony w tym temacie. Spróbuj odświeżyć strone." + disabled_for_topic: "Czat jest wyłączony w tym temacie." + bot: "bot" + create: "Utwórz" + cancel: "Anuluj" + cancel_reply: "Anuluj odpowiedź" + chat_channels: "Kanały" + browse_all_channels: "Przeglądaj wszystkie kanały" + move_to_channel: + confirm_move: "Przenieś wiadomości" + channel_settings: + title: "Ustawienia kanału" + edit: "Edytuj" + add: "Dodaj" + close_channel: "Zamknij kanał" + open_channel: "Otwórz kanał" + delete_channel: "Usuń kanał" + join_channel: "Dołącz do kanału" + leave_channel: "Opuść kanał" + join: "Dołącz" + leave: "Opuść" + channel_open: + title: "Otwórz kanał" + channel_close: + title: "Zamknij kanał" + channel_delete: + title: "Usuń kanał" + confirm_channel_name: "Wpisz nazwę kanału" + channels_list_popup: + browse: "Przeglądaj kanały" + click_to_join: "Kliknij tutaj, aby wyświetlić dostępne kanały." + close: "Zamknij" + collapse: "Zwiń szufladę czatu" + confirm_flag: "Czy na pewno chcesz oflagować wiadomość od %{username}?" + deleted: "Wiadomość została usunięta. [view]" + delete: "Usuń" + edited: "edytowano" + joined: "dołączył" + empty_state: + direct_message: "Możesz także rozpocząć osobisty czat z jednym lub kilkoma użytkownikami." + email_frequency: + never: "Nigdy" + enable: "Włącz czat" + flagged: "Ta wiadomość została oznaczona do sprawdzenia" + invalid_access: "Nie masz dostępu do tego kanału czatu" + in_reply_to: "W odpowiedzi na" + heading: "Czat" + join: "Dołącz" + new_messages: "nowe wiadomości" + mention_warning: + dismiss: "odrzuć" + invitations_sent: + one: "Zaproszenie wysłane" + few: "Zaproszenia wysłane" + many: "Zaproszenia wysłane" + other: "Zaproszenia wysłane" + invite: "Zaproś do kanału" + without_membership: + one: "%{usernames} nie dołączył do tego kanału." + few: "%{usernames} nie dołączyli do tego kanału." + many: "%{usernames} nie dołączyli do tego kanału." + other: "%{usernames} nie dołączyli do tego kanału." + no_public_channels: "Nie dołączyłeś do żadnego kanału." + open: "Otwórz czat" + open_full_page: "Otwórz czat na pełnym ekranie" + open_message: "Otwórz wiadomość na czacie" + placeholder_self: "Zanotuj coś" + placeholder_others: "Czat z %{messageRecipient}" + placeholder_new_message_disallowed: "Kanał ma %{status}, nie możesz teraz wysyłać nowych wiadomości." + placeholder_silenced: "W tej chwili nie możesz wysyłać wiadomości." + remove_upload: "Usuń plik" + react: "Zareaguj z emoji" + reply: "Odpowiedz" + edit: "Edytuj" + copy_link: "Skopiuj link" + rebake_message: "Przebuduj HTML" + retry_staged_message: + action: "Wyślij ponownie?" + restore: "Przywróć usuniętą wiadomość" + save: "Zapisz" + select: "Wybierz" + silence: "Wycisz użytkownika" + scroll_to_bottom: "Przewiń na dół" + scroll_to_new_messages: "Zobacz nowe wiadomości" + sound: + title: "Dźwięk powiadomienia czatu na pulpicie" + sounds: + none: "Brak" + bell: "Dzwonek" + ding: "Ding" + title: "czat" + title_capitalized: "Czat" + upload: "Dołącz plik" + uploaded_files: + one: "%{count} plik" + few: "%{count} pliki" + many: "%{count} plików" + other: "%{count} plików" + you_flagged: "Oflagowałeś tę wiadomość" + exit: "powrót" + channel_status: + read_only_header: "Kanał jest tylko do odczytu" + read_only: "Tylko do odczytu" + archived_header: "Kanał został zarchiwizowany" + archived: "Zarchiwizowany" + closed_header: "Kanał jest zamknięty" + closed: "Zamknięty" + open_header: "Kanał jest otwarty" + open: "Otwarty" + browse: + title: Kanały + filter_closed: Zamknięty + filter_archived: Zarchiwizowany + chat_message_separator: + today: Dzisiaj + yesterday: Wczoraj + about_view: + title: Tytuł + description: Opis + channel_info: + back_to_all_channels: "Wszystkie kanały" + back_to_channel: "Powrót" + tabs: + members: Członkowie + settings: Ustawienia + channel_edit_title_modal: + title: Edytuj tytuł + input_placeholder: Dodaj tytuł + channel_edit_description_modal: + title: Edytuj opis + input_placeholder: Dodaj opis + direct_message_creator: + title: Nowa wiadomość + prefix: "Do:" + no_results: Brak wyników + channel_selector: + title: "Przejdź do kanału" + no_channels: "Żadne kanały nie pasują do Twojego wyszukiwania" + channel: + no_memberships_found: Nie znaleziono członków + memberships_count: + one: 1 członek + few: "%{count} członków" + many: "%{count} członków" + other: "%{count} członków" + create_channel: + choose_category: + label: "Wybierz kategorię" + none: "wybierz jeden..." + default_hint: Zarządzaj dostępem, odwiedzając ustawienia bezpieczeństwa %{category} + create: "Utwórz kanał" + description: "Opis (opcjonalnie)" + name: "Nazwa kanału" + type: "Typ" + types: + category: "Kategoria" + topic: "Temat" + reviewable: + type: "Wiadomość na czacie" + reactions: + only_you: "Zareagowałeś z :%{emoji}:" + and_others: "Ty, %{usernames} zareagowaliście z :%{emoji}:" + only_others: "%{usernames} zareagowali z :%{emoji}:" + others_and_more: "%{usernames} i %{more} inni reagowali z :%{emoji}:" + you_others_and_more: "Ty, %{usernames} i %{more} inni zareagowaliście z :%{emoji}:" + composer: + toggle_toolbar: "Przełącz pasek narzędzi" + italic_text: "podkreślony tekst" + bold_text: "pogrubiony tekst" + code_text: "kod" + quote: + copy_success: "Cytat z czatu skopiowany do schowka" + notification_levels: + never: "Nigdy" + mention: "Tylko dla wzmianek" + always: "Dla całej aktywności" + settings: + desktop_notification_level: "Powiadomienia na pulpicie" + follow: "Dołącz" + mobile_notification_level: "Mobilne powiadomienia push" + mute: "Wycisz kanał" + notifications: "Powiadomienia" + preview: "Podgląd" + save: "Zapisz" + saved: "Zapisano" + unfollow: "Opuść" + admin: + title: "Czat" + direct_messages: + title: "Czat osobisty" + new: "Nowy osobisty czat" + leave: "Opuść ten osobisty czat" + incoming_webhooks: + back: "Powrót" + channel_placeholder: "Wybierz kanał" + confirm_destroy: "Czy na pewno chcesz usunąć tego przychodzącego webhooka? Tego nie można cofnąć." + current_emoji: "Aktualne emoji" + description: "Opis" + delete: "Usuń" + emoji: "Emoji" + emoji_instructions: "Awatar systemowy zostanie użyty, jeśli emotikon pozostanie pusty." + name: "Nazwa" + name_placeholder: "nazwa..." + new: "Nowy przychodzący webhook" + none: "Nie utworzono żadnych istniejących przychodzących webhooków." + no_emoji: "Nie wybrano emotikonów" + post_to: "Opublikuj w" + reset_emoji: "Zresetuj emotikony" + save: "Zapisz" + edit: "Edytuj" + select_emoji: "Wybierz emoji" + system: "system" + title: "Przychodzące webhooki" + url: "URL" + username: "Nazwa użytkownika" + username_instructions: "Nazwa użytkownika bota, który publikuje na kanale. Domyślnie \"system\" gdy pozostanie puste." + selection: + cancel: "Anuluj" + copy: "Kopiuj" + move_selection_to_channel: "Przejdź do kanału" + error: "Wystąpił błąd podczas przenoszenia wiadomości czatu" + title: "Przenieś czat do tematu" + new_topic: + title: "Przenieś do nowego tematu" + instructions: + one: "Zamierzasz utworzyć nowy temat i wypełnić go wybraną wiadomością czatu." + few: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + many: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + other: "Zamierzasz utworzyć nowy temat i wypełnić go %{count} wybranymi wiadomościami czatu." + existing_topic: + title: "Przenieś się do istniejącego tematu" + new_message: + title: "Przenieś do nowej wiadomości" + replying_indicator: + single_user: "%{username} pisze" + multiple_users: "%{commaSeparatedUsernames} i %{lastUsername} piszą" + retention_reminders: + public: "Historia kanału jest przechowywana przez %{days} dni." + dm: "Historia osobistego czatu jest przechowywana przez %{days} dni." + topic_button_title: "Czat" + emoji_picker: + no_results: "Brak wyników" + notifications: + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'wspomniał o Tobie w "%{channel}"' + chat_message: "Nowa wiadomość na czacie" + chat_quoted: "%{username} zacytował Twoją wiadomość na czacie" + discourse_automation: + scriptables: + send_chat_message: + title: Wyślij wiadomość na czacie + fields: + chat_channel_id: + label: ID kanału czatu + message: + label: Wiadomość + sender: + label: Nadawca + review: + types: + reviewable_chat_message: + flagged_by: "Oflagowane przez" + keyboard_shortcuts_help: + chat: + title: "Czat" diff --git a/plugins/chat/config/locales/client.pt.yml b/plugins/chat/config/locales/client.pt.yml new file mode 100644 index 00000000000..298ba523c1d --- /dev/null +++ b/plugins/chat/config/locales/client.pt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: diff --git a/plugins/chat/config/locales/client.pt_BR.yml b/plugins/chat/config/locales/client.pt_BR.yml new file mode 100644 index 00000000000..99ba608ddf3 --- /dev/null +++ b/plugins/chat/config/locales/client.pt_BR.yml @@ -0,0 +1,275 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + js: + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Mostrando todas as mensagens" + already_enabled: "O chat já foi ativado neste tópico. Atualize." + disabled_for_topic: "O chat está desativado neste tópico." + bot: "robô" + create: "Criar" + cancel: "Cancelar" + cancel_reply: "Cancelar resposta" + chat_channels: "Canais" + browse_all_channels: "Navegar por todos os canais" + channel_settings: + title: "Definições do canal" + edit: "Editar" + add: "Adicionar" + close_channel: "Fechar canal" + open_channel: "Abrir canal" + archive_channel: "Arquivar canal" + delete_channel: "Excluir canal" + join_channel: "Entrar no canal" + leave_channel: "Sair do canal" + join: "Participar" + leave: "Sair" + channels_list_popup: + browse: "Navegar por canais" + click_to_join: "Clique aqui para visualizar os canais disponíveis." + close: "Fechar" + collapse: "Recolher gaveta de chat" + confirm_flag: "Tem certeza de que deseja sinalizar a mensagem de %{username}?" + deleted: "Uma mensagem foi excluída. [view]" + delete: "Excluir" + edited: "editou" + empty_state: + direct_message: "Você também pode iniciar um chat pessoal com um(as) ou mais usuários(as)." + email_frequency: + never: "Nunca" + enable: "Ativar chat" + flag: "Sinalizar" + flagged: "Esta mensagem foi sinalizada para revisão" + invalid_access: "Você não tem acesso para ver este canal de chat" + in_reply_to: "Em resposta a" + heading: "Chat" + join: "Participar" + new_messages: "novas mensagens" + mention_warning: + cannot_see: + one: "%{usernames} não pode acessar este canal e não recebeu notificação." + other: "%{usernames} não podem acessar este canal e não receberam notificação." + dismiss: "ignorar" + invitations_sent: + one: "Convite enviado" + other: "Convites enviados" + invite: "convidar para canal" + without_membership: + one: "%{usernames} não entrou neste canal." + other: "%{usernames} não entraram neste canal." + no_public_channels: "Você não entrou em nenhum canal." + only_chat_push_notifications: + title: "Enviar apenas notificações por push" + description: "Bloquear envio de todas as notificações por push não relacionadas a chat" + open: "Abrir chat" + open_full_page: "Abrir chat em tela cheia" + open_message: "Abrir mensagem no chat" + placeholder_self: "Anotar algo" + placeholder_others: "Conversar com %{messageRecipient}" + placeholder_start_conversation: Iniciar uma conversa com %{usernames} + remove_upload: "Remover arquivo" + react: "Reagir com emoji" + reply: "Responder" + edit: "Editar" + copy_link: "Copiar link" + rebake_message: "Reconstruir HTML" + restore: "Restaurar mensagem excluída" + save: "Salvar" + select: "Selecionar" + scroll_to_bottom: "Rolar para a parte inferior" + sound: + title: "Som de notificação do chat no desktop" + sounds: + none: "Nenhum" + bell: "Sino" + ding: "Ding" + title: "chat" + title_capitalized: "Chat" + upload: "Anexar arquivo" + uploaded_files: + one: "%{count} arquivo" + other: "%{count} arquivos" + you_flagged: "Você sinalizou esta mensagem" + exit: "voltar" + channel_status: + archived_header: "O canal está arquivado" + archived: "Arquivado" + closed_header: "Canal fechado" + closed: "Fechado" + browse: + title: Canais + filter_all: Todos + filter_open: Abertos + filter_closed: Fechados + filter_archived: Arquivados + filter_input_placeholder: Pesquisar canal por nome + chat_message_separator: + today: Hoje + yesterday: Ontem + members_view: + filter_placeholder: Encontrar membros + about_view: + associated_topic: Tópico vinculado + associated_category: Categoria vinculada + title: Título + description: Descrição + channel_info: + back_to_all_channels: "Todos os canais" + back_to_channel: "Voltar" + tabs: + about: Sobre + members: Membros + settings: Definições + channel_edit_title_modal: + title: Editar título + input_placeholder: Adicione um título + description: Dê um breve título descritivo ao seu canal + direct_message_creator: + prefix: "Para:" + no_results: Nenhum resultado + channel_selector: + title: "Pular para o canal" + no_channels: "Nenhum canal corresponde à sua pesquisa" + channel: + no_memberships: Este canal não tem membros + no_memberships_found: Nenhum membro encontrado + create_channel: + choose_category: + label: "Escolha uma categoria" + none: "selecionar um..." + default_hint: Gerencie o acesso ao acessar as %{category} configurações de segurança + create: "Criar canal" + description: "Descrição (opcional)" + name: "Nome do canal" + type: "Tipo" + types: + category: "Categoria" + topic: "Tópico" + reviewable: + type: "Mensagem de chat" + reactions: + only_you: "Você reagiu com :%{emoji}:" + and_others: "Você, %{usernames} reagiu com :%{emoji}:" + only_others: "%{usernames} reagiu com :%{emoji}:" + others_and_more: "%{usernames} e mais %{more} reagiram com :%{emoji}:" + you_others_and_more: "Você, %{usernames} e mais %{more} reagiram com :%{emoji}:" + composer: + toggle_toolbar: "Ativar/desativar barra de ferramentas" + notification_levels: + never: "Nunca" + mention: "Apenas para menções" + always: "Para todas as atividades" + settings: + enable_auto_join_users: "Adicionar automaticamente todos os usuários ativos recentemente" + disable_auto_join_users: "Parar de adicionar usuários automaticamente" + desktop_notification_level: "Notificações do desktop" + follow: "Participar" + mobile_notification_level: "Notificações por push em dispositivos móveis" + mute: "Silenciar canal" + muted_on: "Ligado" + muted_off: "Desligado" + notifications: "Notificações" + preview: "Pré-visualizar" + save: "Salvar" + saved: "Salvou" + unfollow: "Sair" + admin: + title: "Chat" + direct_messages: + title: "Chat pessoal" + new: "Novo chat pessoal" + create: "Ir" + leave: "Sair deste chat pessoal" + incoming_webhooks: + back: "Voltar" + channel_placeholder: "Selecione um canal" + confirm_destroy: "Tem certeza de que deseja excluir este webhook recebido? Isso não pode ser desfeito." + current_emoji: "Emoji atual" + description: "Descrição" + delete: "Excluir" + emoji: "Emoji" + emoji_instructions: "O avatar do sistema será usado se o emoji for deixado em branco." + name: "Nome" + name_placeholder: "nome..." + new: "Novo webhook recebido" + none: "Nenhum webhook recebido existente foi criado." + no_emoji: "Nenhum emoji selecionado" + post_to: "Postar para" + reset_emoji: "Redefinir emoji" + save: "Salvar" + edit: "Editar" + select_emoji: "Escolher emoji" + system: "sistema" + title: "Webhooks recebidos" + url: "URL" + username: "Nome de usuário(a)" + username_instructions: "Nome de usuário(a) de robô que posta no canal. Padrão para \"sistema\" quando deixado em branco." + selection: + cancel: "Cancelar" + error: "Houve um erro ao mover as mensagens de chat" + title: "Mover chat para tópico" + new_topic: + title: "Mover para novo tópico" + instructions: + one: "Você está prestes a criar um novo tópico e preenchê-lo com a mensagem de chat selecionada." + other: "Você está prestes a criar um novo tópico e preenchê-lo com as %{count} mensagens de chat selecionadas." + existing_topic: + title: "Mover para tópico existente" + instructions: + one: "Escolha o tópico para o qual você gostaria de mover a mensagem de chat." + other: "Escolha o tópico para o qual você gostaria de mover as %{count} mensagens de chat." + new_message: + title: "Mover para nova mensagem" + instructions: + one: "Você está prestes a criar uma nova mensagem e preenchê-la com a mensagem de chat selecionada." + other: "Você está prestes a criar uma nova mensagem e preenchê-la com as %{count} mensagens de chat selecionadas." + replying_indicator: + single_user: "%{username} está digitando" + multiple_users: "%{commaSeparatedUsernames} e %{lastUsername} estão digitando" + many_users: + one: "%{commaSeparatedUsernames} e mais %{count} estão digitando" + other: "%{commaSeparatedUsernames} e mais %{count} estão digitando" + retention_reminders: + public: "Histórico do canal é mantido por %{days} dias." + dm: "Histórico de conversas pessoais é mantido por %{days} dias." + topic_button_title: "Chat" + emoji_picker: + no_results: "Nenhum resultado" + notifications: + popup: + chat_mention: + direct: 'mencionou você em "%{channel}"' + chat_message: "Nova mensagem de chat" + titles: + chat_mention: "Menção no chat" + chat_invitation: "Convite para chat" + action_codes: + chat: + enabled: '%{who} ativou o %{when}' + disabled: "%{who} fechou o chat %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensagem de chat + fields: + chat_channel_id: + label: ID do canal de chat + message: + label: Mensagem + sender: + label: Remetente + description: Padrões do sistema + review: + types: + reviewable_chat_message: + title: "Mensagem de chat sinalizada" + flagged_by: "Sinalizada por" + keyboard_shortcuts_help: + chat: + title: "Chat" diff --git a/plugins/chat/config/locales/client.ro.yml b/plugins/chat/config/locales/client.ro.yml new file mode 100644 index 00000000000..08a77f812ee --- /dev/null +++ b/plugins/chat/config/locales/client.ro.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: diff --git a/plugins/chat/config/locales/client.ru.yml b/plugins/chat/config/locales/client.ru.yml new file mode 100644 index 00000000000..593e011890c --- /dev/null +++ b/plugins/chat/config/locales/client.ru.yml @@ -0,0 +1,472 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Изменение статуса канала чата" + chat_channel_delete: "Удаление канала чата" + api: + scopes: + descriptions: + chat: + create_message: "Создать сообщение чата в указанном канале." + about: + chat_messages_count: "Сообщения чата" + chat_channels_count: "Каналы чата" + chat_users_count: "Пользователи чата" + chat: + dates: + time_tiny: "ч:мм" + all_loaded: "Отобразить все сообщения" + already_enabled: "Чат в этой теме уже включён . Пожалуйста, обновите страницу." + disabled_for_topic: "Чат в этой теме отключён." + bot: "Бот" + create: "Создать" + cancel: "Отменить" + cancel_reply: "Отменить ответ" + chat_channels: "Каналы" + browse_all_channels: "Просмотреть все каналы" + move_to_channel: + title: "Переместить сообщения в канал" + instructions: + one: "Вы перемещаете 1 сообщение. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что это сообщение было перемещено." + few: "Вы перемещаете %{count} сообщения. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + many: "Вы перемещаете %{count} сообщений. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + other: "Вы перемещаете %{count} сообщений. Выберите целевой канал. В канале %{channelTitle} будет создано сообщение, указывающее, что эти сообщения были перемещены." + confirm_move: "Переместить сообщения" + channel_settings: + title: "Настройки канала" + edit: "Изменить" + add: "Добавить" + close_channel: "Закрыть канал" + open_channel: "Открыть канал" + archive_channel: "Архивировать канал" + delete_channel: "Удалить канал" + join_channel: "Подписаться на канал" + leave_channel: "Покинуть канал" + join: "Подписаться" + leave: "Отписаться" + channel_archive: + title: "Архивировать канал" + instructions: "

Архивация канала переводит его в режим только для чтения и перемещает все сообщения из канала в новую или существующую тему. В этом режиме нельзя отправлять новые сообщения, а существующие сообщения нельзя редактировать или удалять.

Вы действительно хотите заархивировать канал %{channelTitle}?

" + process_started: "Процесс архивации запущен. Это окно закроется в ближайшее время, и вы получите личное сообщение, когда процесс архивации будет завершён." + retry: "Повторить" + channel_open: + title: "Открыть канал" + instructions: "Открытие канала; все пользователи смогут отправлять и редактировать свои сообщения." + channel_close: + title: "Закрыть канал" + instructions: "Закрытие канала; запрет пользователям, не являющихся сотрудниками, отправлять новые или редактировать существующие сообщения. Вы действительно хотите закрыть этот канал?" + channel_delete: + title: "Удалить канал" + instructions: "

Удаление канала %{name} и истории чата. Все сообщения и связанные с ними данные, такие как эмодзи и загрузки, будут безвозвратно удалены. Если вы не хотите использовать канал, при этом сохранив его историю, вы можете его заархивировать.

Вы действительно хотите навсегда удалить канал? Для подтверждения введите название канала в расположенное ниже поле.

" + confirm: "Я понимаю последствия, удалить канал" + confirm_channel_name: "Введите название канала" + process_started: "Процесс удаления канала запущен. Это окно закроется в ближайшее время, вы больше не увидите удалённый канал." + channels_list_popup: + browse: "Просмотр каналов" + create: "Новый канал" + click_to_join: "Нажмите здесь для просмотра доступных каналов." + close: "Закрыть" + collapse: "Свернуть чат" + confirm_flag: "Вы действительно хотите пожаловаться на сообщение пользователя %{username}?" + deleted: "Сообщение было удалено. [view]" + hidden: "Сообщение было скрыто. [view]" + delete: "Удалить" + edited: "изменено" + muted: "Отключённый" + joined: "подписан" + empty_state: + direct_message_cta: "Начать личный чат" + direct_message: "Вы также можете начать личный чат с одним или несколькими пользователями." + title: "Каналы не обнаружены" + email_frequency: + description: "Мы отправим вам письмо только в том случае, если вы не были онлайн последние 15 минут." + never: "Никогда" + title: "Настройка почтовых уведомлений" + when_away: "Если вы находитесь офлайн" + enable: "Включить чат" + flag: "Флаг" + emoji: "Вставить эмодзи" + flagged: "Это сообщение было отправлено на премодерацию" + invalid_access: "У вас нет доступа для просмотра этого канала" + invitation_notification: "Пользователь %{username} пригласил вас присоединиться к каналу" + in_reply_to: "В ответ на" + heading: "Чат" + join: "Подписаться" + new_messages: "Новые сообщения" + mention_warning: + cannot_see: + one: "Пользователь %{usernames} не может получить доступ к этому каналу и не был уведомлён." + few: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлены." + many: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлены." + other: "Пользователи %{usernames} не могут получить доступ к этому каналу и не были уведомлены." + dismiss: "Отклонить" + invitations_sent: + one: "Приглашение отправлено" + few: "Приглашения отправлены" + many: "Приглашений отправлены" + other: "Приглашений отправлены" + invite: "Пригласить в канал" + without_membership: + one: "Пользователь %{usernames} не присоединился к этому каналу." + few: "Пользователи %{usernames} не присоединились к этому каналу." + many: "Пользователи %{usernames} не присоединились к этому каналу." + other: "Пользователи %{usernames} не присоединились к этому каналу." + aria_roles: + header: "Заголовок чата" + composer: "Редактор чата" + channels_list: "Список каналов чата" + no_public_channels: "Вы не присоединились ни к одному каналу." + only_chat_push_notifications: + title: "Отправлять только push-уведомления в чате" + description: "Запретить отправку всех push-уведомлений, не связанных с чатом." + ignore_channel_wide_mention: + title: "Игнорировать на канале массовые упоминания" + description: "Не отправлять массовые уведомления при использовании на канале переменных @here и @all." + open: "Открыть чат" + open_full_page: "Открыть полноэкранный чат" + close_full_page: "Закрыть полноэкранный чат" + open_message: "Открыть сообщение в чате" + placeholder_self: "Напишите что-нибудь" + placeholder_others: "Чат с %{messageRecipient}" + placeholder_new_message_disallowed: "Канал %{status}, в данный момент вы не можете отправлять новые сообщения." + placeholder_silenced: "В настоящее время вы не можете отправлять сообщения." + placeholder_start_conversation: Начать беседу с пользователем %{usernames} + remove_upload: "Удалить файл" + react: "Реакция с помощью эмодзи" + reply: "Ответить" + edit: "Изменить" + copy_link: "Копировать ссылку" + rebake_message: "Перестроить HTML" + retry_staged_message: + title: "Ошибка сети" + action: "Отправить ещё раз?" + unreliable_network: "Сеть ненадёжна, отправка сообщений и сохранение черновиков могут не работать" + bookmark_message: "Закладка" + bookmark_message_edit: "Редактировать закладку" + restore: "Восстановить удаленное сообщение" + save: "Сохранить" + select: "Выбрать" + silence: "Заблокировать пользователя" + return_to_list: "Вернуться к списку каналов" + scroll_to_bottom: "Прокрутка вниз" + scroll_to_new_messages: "Новые сообщения" + sound: + title: "Звук уведомления" + sounds: + none: "Нет" + bell: "Колокольчик" + ding: "Звонок" + title: "чат" + title_capitalized: "Чат" + upload: "Прикрепить файл" + uploaded_files: + one: "%{count} файл" + few: "%{count} файла" + many: "%{count} файлов" + other: "%{count} файлов" + you_flagged: "Вы пожаловались на это сообщение" + exit: "Назад" + channel_status: + read_only_header: "Канал только для чтения" + read_only: "Только для чтения" + archived_header: "Канал заархивирован" + archived: "Архивные" + archive_failed: "Во время архивации поизошла ошибка. %{completed}/%{total} сообщений были заархивированы в целевой теме. Нажмите 'Повторить', чтобы попытаться завершить архивирование." + archive_completed: "См. архивную тему." + closed_header: "Канал закрыт" + closed: "Закрыт" + open_header: "Канал открыт" + open: "Открыт" + browse: + title: Каналы + filter_all: Все + filter_open: Открытые + filter_closed: Закрытые + filter_archived: Архивирован + filter_input_placeholder: Поиск канала по названию + chat_message_separator: + today: Сегодня + yesterday: Вчера + members_view: + filter_placeholder: Найти участников + about_view: + associated_topic: Связанная тема + associated_category: Связанный раздел + title: Название + description: Описание + channel_info: + back_to_all_channels: "Все каналы" + back_to_channel: "Назад" + tabs: + about: Информация о канале + members: Участники + settings: Настройки + channel_edit_title_modal: + title: Изменить название + input_placeholder: Добавьте название + description: Дайте короткое описательное название вашему каналу + channel_edit_description_modal: + title: Изменить описание + input_placeholder: Добавить описание + description: Расскажите, о чем этот канал + direct_message_creator: + title: Новое сообщение + prefix: "Кому:" + no_results: Нет результатов + selected_user_title: "Отменить выбор пользователя %{username}" + channel_selector: + title: "Перейти на канал" + no_channels: "Нет каналов, соответствующих вашему запросу" + channel: + no_memberships: На этом канале нет участников + no_memberships_found: Участники не найдены + memberships_count: + one: 1 участник + few: "%{count} участника" + many: "%{count} участников" + other: "%{count} участников" + create_channel: + auto_join_users: + public_category_warning: "Раздел %{category} является общедоступным. Автоматически добавлять в этот канал всех активных пользователей?" + warning_groups: + one: Автоматически добавить %{members_count} пользователей из группы %{group_1}? + few: Автоматически добавить %{members_count} пользователей из группы %{group_1} и группы %{group_2}? + many: Автоматически добавить %{members_count} пользователей из группы %{group_1} и группы %{group_2}? + other: Автоматически добавить %{members_count} пользователей из группы %{group_1} и группы %{group_2}? + warning_multiple_groups: Автоматически добавить %{members_count} пользователей из группы %{group_1} и ещё из %{count} групп? + choose_category: + label: "Выберите раздел" + none: "выберите раздел..." + default_hint: Управляйте доступом к разделу через %{category}настройки безопасности + hint_groups: + one: Пользователи группы %{hint_1} будут иметь доступ к этому каналу в соответствии с настройками безопасности + few: Пользователи группы %{hint_1} и группы %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + many: Пользователи группы %{hint_1} и группы %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + other: Пользователи группы %{hint_1} и группы %{hint_2} будут иметь доступ к этому каналу в соответствии с настройками безопасности + hint_multiple_groups: Пользователи группы %{hint_1} и ещё %{count} групп будут иметь доступ к этому каналу в соответствии с настройками безопасности + create: "Создать канал" + description: "Описание (необязательно)" + name: "Название канала" + title: "Новый канал" + type: "Тип" + types: + category: "Раздел" + topic: "Тема" + reviewable: + type: "Сообщение чата" + reactions: + only_you: "Вы отреагировали при помощи эмодзи :%{emoji}:" + and_others: "Вы, %{usernames}, отреагировали при помощи эмодзи :%{emoji}:" + only_others: "Пользователи %{usernames} отреагировали при помощи эмодзи %{emoji}:" + others_and_more: "Пользователи %{usernames} и %{more} отреагировали при помощи эмодзи %{emoji}:" + you_others_and_more: "Вы, %{usernames} и %{more}, отреагировали при помощи эмодзи %{emoji}:" + composer: + toggle_toolbar: "Переключить панель инструментов" + italic_text: "Курсив" + bold_text: "Жирный" + code_text: "Код" + quote: + original_channel: 'Первоначально отправлено в %{channel}' + copy_success: "Цитата из чата скопирована в буфер обмена" + notification_levels: + never: "Никогда" + mention: "Только для упоминаний" + always: "Для всех действий" + settings: + enable_auto_join_users: "Автоматически добавлять всех активных пользователей" + disable_auto_join_users: "Остановить автоматическое добавление пользователей" + auto_join_users_warning: "Любой пользователь, не являющийся участником этого канала, но имеющий доступ к разделу %{category} , будет автоматически подключён. Продолжить?" + desktop_notification_level: "Уведомления на рабочем столе" + follow: "Подписаться" + followed: "Подписан" + mobile_notification_level: "Мобильные push-уведомления" + mute: "Отключить канал" + muted_on: "Включено" + muted_off: "Выключено" + notifications: "Уведомления" + preview: "Предпросмотр" + save: "Сохранить" + saved: "Сохранено" + unfollow: "Отписаться" + admin: + title: "Чат" + direct_messages: + title: "Личный чат" + new: "Новый личный чат" + create: "Создать" + leave: "Выйти из личного чата" + cannot_create: "К сожалению, вы не можете отправлять прямые сообщения." + incoming_webhooks: + back: "Назад" + channel_placeholder: "Выберите канал" + confirm_destroy: "Вы действительно хотите удалить этот входящий вебхук? Это действие не может быть отменено." + current_emoji: "Текущие эмодзи" + description: "Описание" + delete: "Удалить" + emoji: "Эмодзи" + emoji_instructions: "Если оставить эмодзи пустым, будет использоваться системный аватар." + name: "Наименование" + name_placeholder: "наименование..." + new: "Новый входящий вебхук" + none: "Входящие вебхуки не созданы." + no_emoji: "Эмодзи не выбрано" + post_to: "Отправить в" + reset_emoji: "Сброс эмодзи" + save: "Сохранить" + edit: "Изменить" + select_emoji: "Выберите эмодзи" + system: "Системный" + title: "Входящие вебхуки" + url: "URL" + url_instructions: "Этот URL содержит секретное значение — берегите его." + username: "Имя пользователя" + username_instructions: "Имя бота, отправляющего сообщения на канал. Если оставить поле пустым, по умолчанию используется 'Системный'." + instructions: "Входящие вебхуки могут использоваться внешними системами для отправки сообщений в назначенный канал чата в качестве пользователя-бота через конечную точку /hooks/:key. Полезная нагрузка состоит из одного параметра text, который ограничен 2000 символами.

Мы также поддерживаем ограниченные Slack-форматированные текстовые параметры, извлекая ссылки и упоминания на основе формата https://api.slack.com/reference/surfaces/formatting, но для этого необходимо использовать конечную точку /hooks/:key/slack." + selection: + cancel: "Отменить" + quote_selection: "Цитировать в теме" + copy: "Копировать" + move_selection_to_channel: "Переместить в канал" + error: "При перемещении сообщений чата произошла ошибка" + title: "Переместить чат в тему" + new_topic: + title: "Переместить в новую тему" + instructions: + one: "Вы собираетесь создать новую тему и заполнить её сообщением, которое вы выбрали." + few: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + many: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + other: "Вы собираетесь создать новую тему и заполнить её %{count} сообщениями, которые вы выбрали." + instructions_channel_archive: "Вы собираетесь создать новую тему и заархивировать в неё сообщения канала." + existing_topic: + title: "Переместить в существующую тему" + instructions: + one: "Пожалуйста, выберите тему, в которую вы хотите переместить это сообщение." + few: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщения." + many: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщений." + other: "Пожалуйста, выберите тему, в которую вы хотите переместить эти %{count} сообщений." + instructions_channel_archive: "Пожалуйста, выберите тему, в которую вы хотите заархивировать сообщения канала." + new_message: + title: "Переместить в новое сообщение" + instructions: + one: "Вы собираетесь создать новое сообщение и заполнить её сообщением чата, которое вы выбрали." + few: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + many: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + other: "Вы собираетесь создать новое сообщение и заполнить её %{count} сообщениями чата, которые вы выбрали." + replying_indicator: + single_user: "%{username} печатает" + multiple_users: "%{commaSeparatedUsernames} и %{lastUsername} печатают" + many_users: + one: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователь" + few: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователя" + many: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" + other: "Отвечают %{commaSeparatedUsernames} и ещё %{count} пользователей" + retention_reminders: + public: "История канала хранится %{days} дней." + dm: "История личного чата хранится %{days} дней." + topic_button_title: "Чат" + flags: + off_topic: "Это сообщение не имеет отношения к текущему обсуждению, как указано в названии канала, и, вероятно, его следует переместить в другое место." + inappropriate: "Это сообщение содержит контент, который разумный человек счел бы недопустимым, оскорбительным или нарушающим основные принципы нашего сообщества." + spam: "Это сообщение является рекламой. Оно не несёт полезной нагрузки или не имеет отношения к текущему каналу." + notify_user: "Я хочу поговорить с этим человеком напрямую и обсудить его сообщение." + notify_moderators: "Это сообщение требует внимания модератора по причине, не указанной выше." + flagging: + action: "Пожаловаться на сообщение" + emoji_picker: + favorites: "Часто используемые" + smileys_&_emotion: "Смайлики и эмоции" + objects: "Объекты" + people_&_body: "Люди и части тел" + travel_&_places: "Путешествия и места" + animals_&_nature: "Животные и природа" + food_&_drink: "Еда и напитки" + activities: "Деятельность" + flags: "Флаги" + symbols: "Символы" + search_placeholder: "Поиск по названию эмодзи и псевдониму..." + no_results: "Нет результатов" + notifications: + chat_invitation: "пригласил вас присоединиться к каналу чата" + chat_invitation_html: "Пользователь %{username} пригласил вас присоединиться к каналу" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'упомянул вас в "%{channel}"' + direct_html: 'Пользователь %{username} упомянул вас на канале "%{channel}"' + other: 'упомянул %{identifier} в "%{channel}"' + other_html: 'Пользователь %{username} упомянул %{identifier} на канале "%{channel}"' + direct_message_chat_mention: + direct: "упомянул вас в личном чате" + direct_html: "Пользователь %{username} упомянул вас в личном чате" + other: "упомянул %{identifier} в личном чате" + other_html: "Пользователь %{username} упомянул @%{identifier} в личном чате" + chat_message: "Новое сообщение в чате" + chat_quoted: "Пользователь %{username} процитировал ваше сообщение в чате" + titles: + chat_mention: "Упоминание в чате" + chat_invitation: "Приглашение в чат" + chat_quoted: "Цитирование чата" + action_codes: + chat: + enabled: '%{who} включил %{when}' + disabled: "%{who} закрыл чат %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Отправить сообщение в чат + fields: + chat_channel_id: + label: ID канала чата + message: + label: Сообщение + sender: + label: Отправитель + description: Системные значения по умолчанию + review: + transcript: + view: "Просмотр предыдущих сообщений" + types: + reviewable_chat_message: + title: "Сообщение на премодерации" + flagged_by: "Жалоба от" + keyboard_shortcuts_help: + chat: + title: "Чат" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Переключить канал" + open_quick_channel_selector: "%{shortcut} Открыть переключатель каналов" + open_insert_link_modal: "%{shortcut} Вставить гиперссылку (только в редакторе)" + composer_bold: "%{shortcut} Жирный (только в редакторе)" + composer_italic: "%{shortcut} Курсив (только в редакторе)" + composer_code: "%{shortcut} Код (только в редакторе)" + drawer_open: "%{shortcut} Открыть панель чата" + drawer_close: "%{shortcut} Закрыть панель чата" + topic_statuses: + chat: + help: "Чат включён для этой темы" + user: + allow_private_messages: "Разрешить другим пользователям отправлять мне личные сообщения и прямые сообщения в чате" + muted_users_instructions: "Не показывать уведомления, личные сообщения и прямые сообщения в чате от этих пользователей." + allowed_pm_users_instructions: "Разрешить только личные сообщения или прямые сообщения в чате от этих пользователей." + allow_private_messages_from_specific_users: "Разрешить только определённым пользователям отправлять мне личные сообщения или прямые сообщения в чате" + ignored_users_instructions: "Не показывать сообщения, личные сообщения, уведомления, прямые и личные сообщения чата от этих пользователей." + user_menu: + no_chat_notifications_title: "У вас пока нет уведомлений чата" + no_chat_notifications_body: > + На этой панели появится уведомление, когда кто-то напишет вам напрямую или @упомянет вас в чате. Уведомления также будут отправлены на вашу электронную почту, если вы отсутствовали на форуме в течение некоторого времени.

Кликните заголовок в верхней части любого канала чата, чтобы настроить уведомления, которые вы будете получать в этом канале. Для получения дополнительной информации см. настройки уведомлений. + tabs: + chat_notifications: "Уведомления чата" + chat_notifications_with_unread: + one: "Уведомления чата - %{count} непрочитанное уведомление" + few: "Уведомления чата - %{count} непрочитанных уведомления" + many: "Уведомления чата - %{count} непрочитанных уведомлений" + other: "Уведомления чата - %{count} непрочитанных уведомлений" diff --git a/plugins/chat/config/locales/client.sk.yml b/plugins/chat/config/locales/client.sk.yml new file mode 100644 index 00000000000..6f815624081 --- /dev/null +++ b/plugins/chat/config/locales/client.sk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: diff --git a/plugins/chat/config/locales/client.sl.yml b/plugins/chat/config/locales/client.sl.yml new file mode 100644 index 00000000000..23489a48b1f --- /dev/null +++ b/plugins/chat/config/locales/client.sl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: diff --git a/plugins/chat/config/locales/client.sq.yml b/plugins/chat/config/locales/client.sq.yml new file mode 100644 index 00000000000..7f051b7a7cf --- /dev/null +++ b/plugins/chat/config/locales/client.sq.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: diff --git a/plugins/chat/config/locales/client.sr.yml b/plugins/chat/config/locales/client.sr.yml new file mode 100644 index 00000000000..51f68df4d6d --- /dev/null +++ b/plugins/chat/config/locales/client.sr.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: + js: + chat: + dates: + time_tiny: "h:mm" diff --git a/plugins/chat/config/locales/client.sv.yml b/plugins/chat/config/locales/client.sv.yml new file mode 100644 index 00000000000..c8f1475c077 --- /dev/null +++ b/plugins/chat/config/locales/client.sv.yml @@ -0,0 +1,403 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "Chattkanalens status har ändrats" + chat_channel_delete: "Chattkanal raderad" + api: + scopes: + descriptions: + chat: + create_message: "Skapa ett chattmeddelande i en angiven kanal." + about: + chat_messages_count: "Chattmeddelanden" + chat_channels_count: "Chattkanaler" + chat_users_count: "Chattanvändare" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "Visar alla meddelanden" + already_enabled: "Chatt är redan aktiverat för detta ämne. Vänligen uppdatera." + disabled_for_topic: "Chatt är inaktiverat för detta ämne." + bot: "bot" + create: "Skapa" + cancel: "Avbryt" + cancel_reply: "Avbryt svar" + chat_channels: "Kanaler" + browse_all_channels: "Bläddra bland alla kanaler" + move_to_channel: + title: "Flytta meddelanden till kanal" + instructions: + one: "Du flyttar 1 -meddelande. Välj en destinationskanal. Ett platshållarmeddelande kommer att skapas i kanalen %{channelTitle} för att indikera att detta meddelande har flyttats." + other: "Du flyttar %{count} meddelanden. Välj en destinationskanal. Ett platshållarmeddelande kommer att skapas i kanalen %{channelTitle} för att indikera att dessa meddelanden har flyttats." + confirm_move: "Flytta meddelanden" + channel_settings: + title: "Kanalinställningar" + edit: "Redigera" + add: "Lägg till" + close_channel: "Stäng kanal" + open_channel: "Öppna kanal" + archive_channel: "Arkivera kanal" + delete_channel: "Radera kanal" + join_channel: "Gå med i kanal" + leave_channel: "Lämna kanal" + join: "Gå med" + leave: "Lämna" + channel_archive: + title: "Arkivera kanal" + instructions: "

Arkivering av en kanal sätter den i skrivskyddat läge och flyttar alla meddelanden från kanalen till ett nytt eller existerande ämne. Inga nya meddelanden kan skickas och inga befintliga meddelanden kan redigeras eller raderas.

Är du säker på att du vill arkivera kanalen %{channelTitle}?

" + process_started: "Arkiveringsprocessen har påbörjats. Denna modal kommer snart att stängas och du får ett personligt meddelande när arkiveringen är klar." + retry: "Försök igen" + channel_open: + title: "Öppna kanal" + instructions: "Öppnar kanalen igen, alla användare kommer att kunna skicka meddelanden och redigera sina befintliga meddelanden." + channel_close: + title: "Stäng kanal" + instructions: "Genom att stänga kanalen hindras användare som inte är personal att skicka nya meddelanden eller redigera befintliga meddelanden. Är du säker på att du vill stänga denna kanal?" + channel_delete: + title: "Radera kanal" + instructions: "

Tar bort %{name} kanalen och chatthistoriken. Alla meddelanden och relaterad data, såsom reaktioner och uppladdningar, kommer att raderas permanent. Om du vill bevara kanalhistoriken men avveckla den, kanske du vill arkivera kanalen istället.

Är du säker på att du permanent vill ta bort kanalen? För att bekräfta, skriv in namnet på kanalen i rutan nedan.

" + confirm: "Jag förstår konsekvenserna, radera kanalen" + confirm_channel_name: "Ange kanalnamn" + process_started: "Processen för att radera kanalen har påbörjats. Denna modal kommer att stängas inom kort och du kommer inte längre att se den raderade kanalen någonstans." + channels_list_popup: + browse: "Bläddra bland kanaler" + click_to_join: "Klicka här för att se tillgängliga kanaler." + close: "Stäng" + collapse: "Komprimera chattruta" + confirm_flag: "Är du säker på att du vill flagga %{username}:s meddelande?" + deleted: "Ett meddelande raderades. [view]" + hidden: "Ett meddelande doldes. [view]" + delete: "Radera" + edited: "redigerad" + muted: "tystad" + joined: "anslöt sig" + empty_state: + direct_message_cta: "Starta en personlig chatt" + direct_message: "Du kan också starta en personlig chatt med en eller flera användare." + title: "Inga kanaler hittades" + email_frequency: + description: "Vi skickar bara e-post till dig om vi inte har sett dig under de senaste 15 minuterna." + never: "Aldrig" + title: "E-postaviseringar" + when_away: "Endast när du är borta" + enable: "Aktivera chatt" + flag: "Flagga" + flagged: "Detta meddelande har flaggats för granskning" + invalid_access: "Du har inte tillgång till den här chattkanalen" + invitation_notification: "%{username} bjöd in dig att gå med i en chattkanal" + in_reply_to: "Som svar på" + heading: "Chatt" + join: "Gå med" + new_messages: "nya meddelanden" + mention_warning: + cannot_see: + one: "%{usernames} kan inte komma åt den här kanalen och har inte blivit meddelad." + other: "%{usernames} kan inte komma åt den här kanalen och har inte blivit meddelade." + dismiss: "avfärda" + invitations_sent: + one: "Inbjudan skickad" + other: "Inbjudningar skickade" + invite: "Bjud in till kanal" + without_membership: + one: "%{usernames} har inte gått med i den här kanalen." + other: "%{usernames} har inte gått med i den här kanalen." + aria_roles: + header: "Chatthuvud" + composer: "Chattkompositör" + channels_list: "Lista över chattkanaler" + no_public_channels: "Du har inte gått med i några kanaler." + only_chat_push_notifications: + title: "Skicka bara push-meddelanden för chatt" + description: "Blockera alla push-meddelanden som inte är chattmeddelanden från att skickas" + ignore_channel_wide_mention: + title: "Ignorera omnämnanden i hela kanalen" + description: "Skicka inte meddelanden för kanalövergripande omnämnanden (@here och @all)." + open: "Öppna chatt" + open_full_page: "Öppna chatt i helskärmsläge" + close_full_page: "Stäng helskärmschatt" + open_message: "Öppna meddelande i chatten" + placeholder_self: "Gör en anteckning" + placeholder_others: "Chatta med %{messageRecipient}" + placeholder_new_message_disallowed: "Kanalen är %{status}, du kan inte skicka nya meddelanden just nu." + placeholder_silenced: "Du kan inte skicka meddelanden just nu." + placeholder_start_conversation: Starta en konversation med %{usernames} + remove_upload: "Ta bort fil" + react: "Reagera med emoji" + reply: "Svara" + edit: "Redigera" + copy_link: "Kopiera länk" + rebake_message: "Omskapa HTML" + bookmark_message: "Bokmärke" + bookmark_message_edit: "Redigera bokmärke" + restore: "Återställ raderat meddelande" + save: "Spara" + select: "Välj" + silence: "Tysta användare" + return_to_list: "Återgå till listan över kanaler" + scroll_to_bottom: "Scrolla till botten" + scroll_to_new_messages: "Se nya meddelanden" + sound: + title: "Meddelandeljud för chatt via dator" + sounds: + none: "Ingen" + bell: "Bell" + ding: "Ding" + title: "chatt" + title_capitalized: "Chatt" + upload: "Bifoga en fil" + uploaded_files: + one: "%{count} fil" + other: "%{count} filer" + you_flagged: "Du flaggade detta meddelande" + exit: "tillbaka" + channel_status: + read_only_header: "Kanalen är skrivskyddad" + read_only: "Endast läsning" + archived_header: "Kanalen är arkiverad" + archived: "Arkiverad" + archive_failed: "Arkivering av kanalen misslyckades. %{completed}/%{total} meddelanden har arkiverats i destinationsämnet. Tryck på försök igen för att försöka slutföra arkivet." + archive_completed: "Se det arkiverade ämnet" + closed_header: "Kanalen är stängd" + closed: "Stängd" + open_header: "Kanalen är öppen" + open: "Öppen" + browse: + title: Kanaler + filter_all: Alla + filter_open: Öppnad + filter_closed: Stängd + filter_archived: Arkiverad + filter_input_placeholder: Sök kanal efter namn + chat_message_separator: + today: Idag + yesterday: Igår + members_view: + filter_placeholder: Hitta medlemmar + about_view: + associated_topic: Länkat ämne + associated_category: Länkad kategori + title: Titel + description: Beskrivning + channel_info: + back_to_all_channels: "Alla kanaler" + back_to_channel: "Tillbaka" + tabs: + about: Om + members: Medlemmar + settings: Inställningar + channel_edit_title_modal: + title: Redigera titel + input_placeholder: Lägg till en titel + description: Ge en kort beskrivande titel till din kanal + channel_edit_description_modal: + title: Redigera beskrivning + input_placeholder: Lägg till en beskrivning + description: Berätta för folk vad den här kanalen handlar om + direct_message_creator: + title: Nytt meddelande + prefix: "Till:" + no_results: Inga resultat + selected_user_title: "Avmarkera %{username}" + channel_selector: + title: "Byt till kanal" + no_channels: "Inga kanaler matchar din sökning" + channel: + no_memberships: Denna kanal har inga medlemmar + no_memberships_found: Inga medlemmar hittades + memberships_count: + one: 1 medlem + other: "%{count} medlemmar" + create_channel: + auto_join_users: + warning_groups: + one: Lägg automatiskt till %{members_count} användare från %{group_1}? + other: Lägg automatiskt till %{members_count} användare från %{group_1} och %{group_2}? + warning_multiple_groups: Lägg automatiskt till %{members_count} användare från %{group_1} och %{count} andra? + choose_category: + label: "Välj en kategori" + none: "Välj en..." + default_hint: Hantera åtkomst genom att besöka %{category} säkerhetsinställningar + hint_groups: + one: Användare i %{hint_1} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningar + other: Användare i %{hint_1} och %{hint_2} kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna + hint_multiple_groups: Användare i %{hint_1} och %{count} andra grupper kommer att ha åtkomst till denna kanal enligt säkerhetsinställningarna + create: "Skapa kanal" + description: "Beskrivning (valfritt)" + name: "Kanalnamn" + type: "Typ" + types: + category: "Kategori" + topic: "Ämne" + reviewable: + type: "Chatt meddelande" + reactions: + only_you: "Du reagerade med :%{emoji}:" + and_others: "Du, %{usernames} reagerade med :%{emoji}:" + only_others: "%{usernames} reagerade med :%{emoji}:" + others_and_more: "%{usernames} och %{more} andra reagerade med :%{emoji}:" + you_others_and_more: "Du, %{usernames} och %{more} andra reagerade med :%{emoji}:" + composer: + toggle_toolbar: "Växla verktygsfält" + italic_text: "betonad text" + bold_text: "stark text" + code_text: "kod text" + quote: + original_channel: 'Ursprungligen skickad i %{channel}' + copy_success: "Chattcitat kopierat till urklipp" + notification_levels: + never: "Aldrig" + mention: "Endast för omnämnanden" + always: "För all aktivitet" + settings: + auto_join_users_warning: "Varje användare som inte är medlem i den här kanalen och har tillgång till kategorin %{category} kommer att gå med. Är du säker?" + desktop_notification_level: "Skrivbordsaviseringar" + follow: "Gå med" + followed: "Anslöt sig" + mobile_notification_level: "Mobila push-meddelanden" + mute: "Tysta kanal" + muted_on: "På" + muted_off: "Av" + notifications: "Aviseringar" + preview: "Förhandsgranska" + save: "Spara" + saved: "Sparad" + unfollow: "Lämna" + admin: + title: "Chatt" + direct_messages: + title: "Personlig chatt" + new: "Ny personlig chatt" + create: "Kör" + leave: "Lämna den här personliga chatten" + incoming_webhooks: + back: "Tillbaka" + channel_placeholder: "Välj en kanal" + confirm_destroy: "Är du säker på att du vill ta bort denna inkommande webhook? Detta kan inte ångras." + current_emoji: "Nuvarande emoji" + description: "Beskrivning" + delete: "Radera" + emoji: "Emoji" + emoji_instructions: "Systemavatar kommer att användas om emoji lämnas tom." + name: "Namn" + name_placeholder: "namn..." + new: "Ny inkommande webhook" + none: "Inga befintliga inkommande webhooks har skapats." + no_emoji: "Ingen emoji har valts" + post_to: "Posta till" + reset_emoji: "Återställ emoji" + save: "Spara" + edit: "Redigera" + select_emoji: "Välj emoji" + system: "system" + title: "Inkommande webhooks" + url: "URL" + username: "Användarnamn" + username_instructions: "Användarnamn på bot som gör inlägg på kanalen. Standardinställning är 'system' när det lämnas tomt." + selection: + cancel: "Avbryt" + quote_selection: "Citat i ämne" + copy: "Kopiera" + move_selection_to_channel: "Flytta till kanal" + error: "Det uppstod ett fel när chattmeddelanden skulle flyttas" + title: "Flytta chatt till ämne" + new_topic: + title: "Flytta till nytt ämne" + instructions: + one: "Du håller på att skapa ett nytt ämne och fylla det med chattmeddelandet du har valt." + other: "Du håller på att skapa ett nytt ämne och fylla det med de %{count} chattmeddelanden du har valt." + instructions_channel_archive: "Du håller på att skapa ett nytt ämne och arkivera kanalmeddelandena till det." + existing_topic: + title: "Flytta till befintligt ämne" + instructions: + one: "Välj det ämne du vill flytta chattmeddelandet till." + other: "Välj det ämne du vill flytta dessa %{count} chattmeddelanden till." + instructions_channel_archive: "Välj vilket ämne du vill arkivera kanalmeddelanden till." + new_message: + title: "Flytta till nytt meddelande" + instructions: + one: "Du håller på att skapa ett nytt meddelande och fylla det med chattmeddelandet du har valt." + other: "Du håller på att skapa ett nytt ämne och fylla det med de %{count} chattmeddelanden du har valt." + replying_indicator: + single_user: "%{username} skriver" + multiple_users: "%{commaSeparatedUsernames} och %{lastUsername} skriver" + many_users: + one: "%{commaSeparatedUsernames} och %{count} skriver" + other: "%{commaSeparatedUsernames} och %{count} andra skriver" + retention_reminders: + public: "Kanalhistoriken behålls i %{days} dagar." + dm: "Personlig chatthistorik behålls i %{days} dagar." + topic_button_title: "Chatt" + emoji_picker: + no_results: "Inga resultat" + notifications: + chat_invitation: "bjöd in dig att gå med i en chattkanal" + chat_invitation_html: "%{username} bjöd in dig att gå med i en chattkanal" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: 'nämnde dig i "%{channel}"' + direct_html: '%{username} nämnde dig i "%{channel}"' + other: 'nämnde %{identifier} i "%{channel}"' + other_html: '%{username} nämnde %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "nämnde dig i personlig chatt" + direct_html: "%{username} nämnde dig i en personlig chatt" + other: "nämnde %{identifier} i personlig chatt" + other_html: "%{username} nämnde %{identifier} i personlig chatt" + chat_message: "Nytt chattmeddelande" + chat_quoted: "%{username} citerade ditt chattmeddelande" + titles: + chat_mention: "Chatt omnämnande" + chat_invitation: "Chattinbjudan" + chat_quoted: "Chatt citerad" + action_codes: + chat: + enabled: '%{who} aktiverade %{when}' + disabled: "%{who} stängde chatten %{when}" + discourse_automation: + scriptables: + send_chat_message: + title: Skicka chattmeddelande + fields: + chat_channel_id: + label: Chattkanal-ID + message: + label: Meddelande + sender: + label: Avsändare + description: Standard är system + review: + types: + reviewable_chat_message: + title: "Flaggat chattmeddelande" + flagged_by: "Flaggat av" + keyboard_shortcuts_help: + chat: + title: "Chatt" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} Byt kanal" + open_quick_channel_selector: "%{shortcut} Öppna snabbval av kanal" + open_insert_link_modal: "%{shortcut} Infoga hyperlänk (endast kompositör)" + composer_bold: "%{shortcut} Fet (endast kompositör)" + composer_italic: "%{shortcut} Kursiv (endast kompositör)" + composer_code: "%{shortcut} Kod (endast kompositör)" + drawer_open: "%{shortcut} Öppna chattmenyn" + drawer_close: "%{shortcut} Stäng chattmenyn" + topic_statuses: + chat: + help: "Chatt är aktiverat för detta ämne" + user: + allow_private_messages: "Tillåt andra användare att skicka mig personliga meddelanden och chattmeddelanden" + muted_users_instructions: "Neka alla meddelanden, personliga meddelanden och direkta chattmeddelanden från dessa användare." + allowed_pm_users_instructions: "Tillåt endast personliga meddelanden eller chattmeddelanden från dessa användare." + allow_private_messages_from_specific_users: "Tillåt endast specifika användare att skicka personliga meddelanden eller chattmeddelanden" + ignored_users_instructions: "Neka alla inlägg, meddelanden, notifikationer, personliga meddelanden eller direkta chattmeddelanden från dessa användare." diff --git a/plugins/chat/config/locales/client.sw.yml b/plugins/chat/config/locales/client.sw.yml new file mode 100644 index 00000000000..0d7cdd075bf --- /dev/null +++ b/plugins/chat/config/locales/client.sw.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: diff --git a/plugins/chat/config/locales/client.te.yml b/plugins/chat/config/locales/client.te.yml new file mode 100644 index 00000000000..03967bdbb07 --- /dev/null +++ b/plugins/chat/config/locales/client.te.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: diff --git a/plugins/chat/config/locales/client.th.yml b/plugins/chat/config/locales/client.th.yml new file mode 100644 index 00000000000..7de85ff91c4 --- /dev/null +++ b/plugins/chat/config/locales/client.th.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: diff --git a/plugins/chat/config/locales/client.tr_TR.yml b/plugins/chat/config/locales/client.tr_TR.yml new file mode 100644 index 00000000000..3e1142a83a0 --- /dev/null +++ b/plugins/chat/config/locales/client.tr_TR.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: diff --git a/plugins/chat/config/locales/client.uk.yml b/plugins/chat/config/locales/client.uk.yml new file mode 100644 index 00000000000..f1390545d1d --- /dev/null +++ b/plugins/chat/config/locales/client.uk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: diff --git a/plugins/chat/config/locales/client.ur.yml b/plugins/chat/config/locales/client.ur.yml new file mode 100644 index 00000000000..b4a9c21ee2f --- /dev/null +++ b/plugins/chat/config/locales/client.ur.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: diff --git a/plugins/chat/config/locales/client.vi.yml b/plugins/chat/config/locales/client.vi.yml new file mode 100644 index 00000000000..f629dcf5329 --- /dev/null +++ b/plugins/chat/config/locales/client.vi.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: diff --git a/plugins/chat/config/locales/client.zh_CN.yml b/plugins/chat/config/locales/client.zh_CN.yml new file mode 100644 index 00000000000..33f88bd7258 --- /dev/null +++ b/plugins/chat/config/locales/client.zh_CN.yml @@ -0,0 +1,395 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + js: + admin: + logs: + staff_actions: + actions: + chat_channel_status_change: "聊天频道状态已更改" + chat_channel_delete: "聊天频道已删除" + api: + scopes: + descriptions: + chat: + create_message: "在指定频道创建聊天消息。" + about: + chat_messages_count: "聊天消息" + chat_channels_count: "聊天频道" + chat_users_count: "聊天用户" + chat: + dates: + time_tiny: "h:mm" + all_loaded: "显示所有消息" + already_enabled: "此话题已启用聊天。请刷新。" + disabled_for_topic: "此话题已禁用聊天。" + bot: "机器人" + create: "创建" + cancel: "取消" + cancel_reply: "取消回复" + chat_channels: "频道" + browse_all_channels: "浏览所有频道" + move_to_channel: + title: "将消息移至频道" + instructions: + other: "您正在移动 %{count} 消息。请选择一个目标频道。 将在 %{channelTitle} 频道中创建占位符消息,以表明这些消息已被移动。" + confirm_move: "移动消息" + channel_settings: + title: "频道设置" + edit: "编辑" + add: "编辑" + close_channel: "关闭频道" + open_channel: "启用频道" + archive_channel: "归档频道" + delete_channel: "删除频道" + join_channel: "加入频道" + leave_channel: "离开频道" + join: "加入" + leave: "离开" + channel_archive: + title: "归档频道" + instructions: "

归档一个频道会使它进入只读模式,并将该频道的所有信息移到一个新的或现有的主题中。将无法发送新消息,也不能编辑或删除现有的信息。

你确定要对 %{channelTitle} 频道进行存档吗?

" + process_started: "归档过程已经开始。这个弹窗很快就会关闭,当归档过程完成后,你会收到一条私信。" + retry: "重试" + channel_open: + title: "启用频道" + instructions: "重新启用频道,所有用户将能够发送消息并编辑他们现有的消息。" + channel_close: + title: "关闭频道" + instructions: "关闭该频道可以防止非工作人员用户发送新信息或编辑现有信息。你确定要关闭这个频道吗?" + channel_delete: + title: "删除频道" + instructions: "

删除 %{name} 频道和聊天历史。所有信息和相关数据,如回应和上传,将被永久删除。如果你想保留频道历史,并将其停用,你可能想将该频道归档,而不是删除。

你确定你要 ,永久删除 该频道?为了确认,请在下面的方框中输入该频道的名称。

" + confirm: "我明白后果,删除频道" + confirm_channel_name: "输入频道名称" + process_started: "删除该频道的过程已经开始。这个对话框将很快关闭,你将无法看到被删除的频道。" + channels_list_popup: + browse: "浏览频道" + click_to_join: "点击此处查看可用频道。" + close: "关闭" + collapse: "折叠聊天抽屉" + confirm_flag: "确定要举报 %{username} 的消息吗?" + deleted: "一条消息已被删除。[查看]" + hidden: "一条消息已被删除。[查看]" + delete: "删除" + edited: "已编辑" + muted: "已静音" + joined: "已加入" + empty_state: + direct_message_cta: "开始个人聊天" + direct_message: "您还可以与一位或多位用户开始个人聊天。" + title: "未找到频道" + email_frequency: + description: "我们只有在过去15分钟内没有看到你时才会给你发电子邮件。" + never: "永不" + title: "电子邮件通知" + when_away: "仅在离开时" + enable: "启用聊天" + flag: "举报" + flagged: "此消息已被举报,等待审核" + invalid_access: "您无权查看此聊天频道" + invitation_notification: "%{username} 邀请你加入一个聊天频道" + in_reply_to: "回复" + heading: "聊天" + join: "加入" + new_messages: "新消息" + mention_warning: + cannot_see: + other: "%{usernames} 无法访问此频道且未收到通知。" + dismiss: "关闭" + invitations_sent: + other: "已发送邀请" + invite: "邀请加入频道" + without_membership: + other: "%{usernames} 尚未加入此频道。" + aria_roles: + header: "聊天标题" + composer: "聊天输入框" + channels_list: "聊天频道列表" + no_public_channels: "您还没有加入任何频道。" + only_chat_push_notifications: + title: "只发送聊天推送通知" + description: "阻止发送所有非聊天推送通知" + ignore_channel_wide_mention: + title: "忽略频道范围内的提及" + description: "不要发送频道范围内提及的通知(@here 和 @all)" + open: "打开聊天" + open_full_page: "打开全屏聊天" + close_full_page: "关闭全屏聊天" + open_message: "在聊天中打开消息" + placeholder_self: "做些记录" + placeholder_others: "与 %{messageRecipient} 聊天" + placeholder_new_message_disallowed: "频道 %{status},您现在无法发送新消息。" + placeholder_silenced: "您目前无法发送消息。" + placeholder_start_conversation: 与 %{usernames}开始对话 + remove_upload: "移除文件" + react: "使用表情符号回复" + reply: "回复" + edit: "编辑" + copy_link: "复制链接" + rebake_message: "重建 HTML" + retry_staged_message: + title: "网络错误" + action: "重新发送?" + unreliable_network: "网络不稳定,发送消息和保存草稿可能无法正常工作" + bookmark_message: "书签" + bookmark_message_edit: "编辑书签" + restore: "恢复已删除的消息" + save: "保存" + select: "选择" + silence: "静音用户" + return_to_list: "返回频道列表" + scroll_to_bottom: "滚动到底部" + scroll_to_new_messages: "查看新消息" + sound: + title: "桌面聊天通知声音" + sounds: + none: "无" + bell: "钟声" + ding: "叮叮" + title: "聊天" + title_capitalized: "聊天" + upload: "附加文件" + uploaded_files: + other: "%{count} 个文件" + you_flagged: "您已举报此消息" + exit: "返回" + channel_status: + read_only_header: "频道只读" + read_only: "只读" + archived_header: "频道已归档" + archived: "已归档" + archive_failed: "频道归档失败。 %{completed}/%{total} 信息已被归档到 目标主题。请按重试,尝试完成存档。" + archive_completed: "见 归档主题" + closed_header: "频道已关闭" + closed: "已关闭" + open_header: "频道已开启" + open: "开启" + browse: + title: 频道 + filter_all: 全部 + filter_open: 已开启 + filter_closed: 已关闭 + filter_archived: 已归档 + filter_input_placeholder: 按名称搜索频道 + chat_message_separator: + today: 今天 + yesterday: 昨天 + members_view: + filter_placeholder: 查找成员 + about_view: + associated_topic: 已关联的主题 + associated_category: 链接的类别 + title: 标题 + description: 描述 + channel_info: + back_to_all_channels: "所有频道" + back_to_channel: "返回" + tabs: + about: 关于 + members: 成员 + settings: 设置 + channel_edit_title_modal: + title: 编辑标题 + input_placeholder: 添加标题 + description: 为您的频道提供简短的描述性标题 + channel_edit_description_modal: + title: 编辑描述 + input_placeholder: 添加描述 + description: 告诉人们这个频道是关于什么 + direct_message_creator: + title: 新消息 + prefix: "至:" + no_results: 没有结果 + selected_user_title: "取消选择 %{username}" + channel_selector: + title: "跳转到频道" + no_channels: "没有频道符合您的搜索" + channel: + no_memberships: 此频道没有成员 + no_memberships_found: 未找到成员 + memberships_count: + other: "%{count} 个成员" + create_channel: + auto_join_users: + warning_groups: + other: 自动添加 %{members_count} 位来自 %{group_1} 和 %{group_2} 的用户? + warning_multiple_groups: 自动添加 %{members_count} 位来自 %{group_1} 和其他 %{count} 位用户? + choose_category: + label: "选择一个类别" + none: "选择一个…" + default_hint: 访问%{category}安全设置管理访问 + hint_groups: + other: 根据 安全设置 %{hint_1} 和 %{hint_2}的用户将有权访问此频道 + hint_multiple_groups: 根据 安全设置, %{hint_1} 组和其他 %{count} 个组中的用户将有权访问此频道 + create: "创建频道" + description: "描述(可选)" + name: "频道名称" + type: "类型" + types: + category: "类别" + topic: "话题" + reviewable: + type: "聊天消息" + reactions: + only_you: "您回应了 :%{emoji}:" + and_others: "您,%{usernames} 回应了 :%{emoji}:" + only_others: "%{usernames} 回应了 :%{emoji}:" + others_and_more: "%{usernames} 和其他 %{more} 人回应了 :%{emoji}:" + you_others_and_more: "您,%{usernames} 和其他 %{more} 人回应了 :%{emoji}:" + composer: + toggle_toolbar: "切换工具栏" + italic_text: "斜体" + bold_text: "粗体" + code_text: "代码文本" + quote: + original_channel: '最初发送于 %{channel}' + copy_success: "引用的聊天已复制到剪贴板" + notification_levels: + never: "永不" + mention: "仅限提及" + always: "所有活动" + settings: + auto_join_users_warning: "每个不是该频道成员且有权访问 %{category} 类别的用户都将加入。你确定吗?" + desktop_notification_level: "桌面通知" + follow: "加入" + followed: "已加入" + mobile_notification_level: "移动推送通知" + mute: "将频道设为免打扰" + muted_on: "开" + muted_off: "关" + notifications: "通知" + preview: "预览" + save: "保存" + saved: "已保存" + unfollow: "离开" + admin: + title: "聊天" + direct_messages: + title: "个人聊天" + new: "新的个人聊天" + create: "开始" + leave: "离开此个人聊天" + incoming_webhooks: + back: "返回" + channel_placeholder: "选择一个频道" + confirm_destroy: "确定要删除此传入网络钩子吗?此操作无法撤消。" + current_emoji: "当前表情符号" + description: "描述" + delete: "删除" + emoji: "表情符号" + emoji_instructions: "如果表情符号留空,将使用系统头像。" + name: "名称" + name_placeholder: "名称…" + new: "新的传入网络钩子" + none: "未创建现有的传入网络钩子。" + no_emoji: "未选择表情符号" + post_to: "发布到" + reset_emoji: "重置表情符号" + save: "保存" + edit: "编辑" + select_emoji: "选择表情符号" + system: "系统" + title: "传入网络钩子" + url: "URL" + username: "用户名" + username_instructions: "发布到频道的机器人的用户名。留空时默认为“系统”。" + selection: + cancel: "取消" + quote_selection: "在主题中引用" + copy: "复制" + move_selection_to_channel: "移至频道" + error: "移动聊天消息时出错" + title: "将聊天移动到话题" + new_topic: + title: "移动到新话题" + instructions: + other: "您将创建一个新话题并使用您选择的 %{count} 条聊天消息进行填充。" + instructions_channel_archive: "您将要创建一个新主题并将频道消息归档到该主题。" + existing_topic: + title: "移动到现有话题" + instructions: + other: "请选择您要将这 %{count} 条聊天消息移动到的话题。" + instructions_channel_archive: "请选择您要将频道消息归档到的主题。" + new_message: + title: "移动到新消息" + instructions: + other: "您将创建一条新消息并使用您选择的 %{count} 条聊天消息进行填充。" + replying_indicator: + single_user: "%{username} 正在输入" + multiple_users: "%{commaSeparatedUsernames} 和 %{lastUsername} 正在输入" + many_users: + other: "%{commaSeparatedUsernames} 和其他 %{count} 人正在输入" + retention_reminders: + public: "频道历史记录保留 %{days} 天。" + dm: "个人聊天记录保留 %{days} 天。" + topic_button_title: "聊天" + emoji_picker: + no_results: "没有结果" + notifications: + chat_invitation: "邀请您加入聊天频道" + chat_invitation_html: "%{username} 邀请您加入聊天频道" + chat_quoted: "%{username} %{description}" + popup: + chat_mention: + direct: '在“%{channel}”中提到了你' + direct_html: '%{username} 在“%{channel}”中提到了你' + other: '在 "%{channel}" 中提到 %{identifier}' + other_html: '%{username} 在 “%{channel}”中提到 %{identifier}' + direct_message_chat_mention: + direct: "在个人聊天中提到了你" + direct_html: "%{username} 在个人聊天中提到了你" + other: "在个人聊天中提到 %{identifier}" + other_html: "%{username} 个人聊天中提到 %{identifier}" + chat_message: "新的聊天消息" + chat_quoted: "%{username} 引用了你的聊天消息" + titles: + chat_mention: "聊天提及" + chat_invitation: "聊天邀请" + chat_quoted: "已引用聊天" + action_codes: + chat: + enabled: '%{who} 于 %{when} 启用了' + disabled: "%{who} 于 %{when} 关闭了聊天" + discourse_automation: + scriptables: + send_chat_message: + title: 发送聊天消息 + fields: + chat_channel_id: + label: 聊天频道 ID + message: + label: 消息 + sender: + label: 发送人 + description: 默认为“系统” + review: + types: + reviewable_chat_message: + title: "举报的聊天消息" + flagged_by: "举报者" + keyboard_shortcuts_help: + chat: + title: "聊天" + keyboard_shortcuts: + switch_channel_arrows: "%{shortcut} 切换频道" + open_quick_channel_selector: "%{shortcut} 打开快速频道选择器" + open_insert_link_modal: "%{shortcut} 插入超链接(仅输入框)" + composer_bold: "%{shortcut} 粗体(仅输入框中)" + composer_italic: "%{shortcut} 斜体(仅输入框中)" + composer_code: "%{shortcut} 代码(仅输入框中)" + drawer_open: "%{shortcut} 打开聊天面板" + drawer_close: "%{shortcut} 关闭聊天面板" + topic_statuses: + chat: + help: "已为此主题启用聊天" + user: + allow_private_messages: "允许其他用户向我发送私信和发起聊天" + muted_users_instructions: "禁止来自这些用户的所有通知、个人消息和聊天消息。" + allowed_pm_users_instructions: "仅允许来自这些用户的个人消息或聊天消息。" + allow_private_messages_from_specific_users: "只允许特定用户向我发送个人消息或聊天消息" + ignored_users_instructions: "禁止来自这些用户的所有帖子、消息、通知、个人消息和聊天消息。" diff --git a/plugins/chat/config/locales/client.zh_TW.yml b/plugins/chat/config/locales/client.zh_TW.yml new file mode 100644 index 00000000000..7e15fab0018 --- /dev/null +++ b/plugins/chat/config/locales/client.zh_TW.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: diff --git a/plugins/chat/config/locales/server.ar.yml b/plugins/chat/config/locales/server.ar.yml new file mode 100644 index 00000000000..6386f48b589 --- /dev/null +++ b/plugins/chat/config/locales/server.ar.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + chat: + errors: + channel_exists_for_category: "توجد قناة بالفعل في هذه الفئة وبهذا الاسم" + reviewables: + actions: + agree: + title: "موافقة..." + agree_and_keep_message: + title: "الاحتفاظ بالرسالة" + description: "يمكنك الموافقة على البلاغ والاحتفاظ بالرسالة دون تغيير." + agree_and_keep_deleted: + title: "ترك الرسالة محذوفة" + description: "ويمكنك الموافقة على البلاغ وترك الرسالة محذوفة." + agree_and_suspend: + title: "تعليق المستخدم" + description: "يمكنك الموافقة على البلاغ وتعليق المستخدم." + agree_and_silence: + title: "كتم المستخدم" + description: "يمكنك الموافقة على البلاغ وكتم المستخدم." + agree_and_restore: + title: "استعادة الرسالة" + description: "يمكنك استعادة الرسالة حتى يتمكن المستخدمون من رؤيتها." + agree_and_delete: + title: "حذف الرسالة" + description: "يمكنك حذف الرسالة حتى لا يتمكن المستخدمون من رؤيتها." + delete_and_agree: + title: "حذف الرسالة" + disagree_and_restore: + title: "عدم الموافقة واستعادة الرسالة" + description: "يمكنك استعادة الرسالة حتى يتمكن جميع المستخدمين من رؤيتها." + disagree: + title: "عدم الموافقة" + ignore: + title: "تجاهل" + personal_chat: "الدردشة الشخصية" + discourse_automation: + scriptables: + send_chat_message: + title: إرسال رسالة دردشة + reviewable_score_types: + needs_review: + title: "بحاجة إلى المراجعة" + unsubscribe: + chat_summary: + never: أبدًا diff --git a/plugins/chat/config/locales/server.be.yml b/plugins/chat/config/locales/server.be.yml new file mode 100644 index 00000000000..2ea77a0d350 --- /dev/null +++ b/plugins/chat/config/locales/server.be.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: diff --git a/plugins/chat/config/locales/server.bg.yml b/plugins/chat/config/locales/server.bg.yml new file mode 100644 index 00000000000..52333529d3c --- /dev/null +++ b/plugins/chat/config/locales/server.bg.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: diff --git a/plugins/chat/config/locales/server.bs_BA.yml b/plugins/chat/config/locales/server.bs_BA.yml new file mode 100644 index 00000000000..828a7e65af8 --- /dev/null +++ b/plugins/chat/config/locales/server.bs_BA.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: diff --git a/plugins/chat/config/locales/server.ca.yml b/plugins/chat/config/locales/server.ca.yml new file mode 100644 index 00000000000..ec737bc1a5b --- /dev/null +++ b/plugins/chat/config/locales/server.ca.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: diff --git a/plugins/chat/config/locales/server.cs.yml b/plugins/chat/config/locales/server.cs.yml new file mode 100644 index 00000000000..041b2f0bd05 --- /dev/null +++ b/plugins/chat/config/locales/server.cs.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: diff --git a/plugins/chat/config/locales/server.da.yml b/plugins/chat/config/locales/server.da.yml new file mode 100644 index 00000000000..029b82b7a55 --- /dev/null +++ b/plugins/chat/config/locales/server.da.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + chat: + deleted_chat_username: slettet + reviewables: + actions: + agree_and_suspend: + title: "Suspendér Bruger" + agree_and_restore: + title: "Gendan Besked" + description: "Gendan meddelelsen, så brugerne kan se den." + agree_and_delete: + title: "Slet Besked" + description: "Slet meddelelsen, så brugerne ikke kan se den." + delete_and_agree: + title: "Slet Besked" + disagree: + title: "Uenig" + ignore: + title: "Ignorer" + channel: + statuses: + archived: "Arkiveret" + closed: "Lukket" + open: "Åben" + archive: + first_post_raw: "Dette emne er et arkiv af chatkanalen [%{channel_name}] (%{channel_url})." + bookmarkable: + notification_title: "besked i %{channel_name}" + personal_chat: "personlig chat" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nævnte dig i "%{channel}"' + other: '%{username} nævnte %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "%{username} nævnte dig i personlig chat" + other: "%{username} nævnte %{identifier} i personlig chat" + new_chat_message: '%{username} sendte en besked i "%{channel}"' + new_direct_chat_message: "%{username} sendte en besked i personlig chat" + discourse_automation: + scriptables: + send_chat_message: + title: Send chatbesked + reviewable_score_types: + needs_review: + title: "Behøver Gennemgang" diff --git a/plugins/chat/config/locales/server.de.yml b/plugins/chat/config/locales/server.de.yml new file mode 100644 index 00000000000..0303f47611a --- /dev/null +++ b/plugins/chat/config/locales/server.de.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + chat: + errors: + channel_exists_for_category: "Für diese Kategorie und diesen Namen existiert bereits ein Kanal" + reviewables: + actions: + agree: + title: "Zustimmen …" + agree_and_keep_message: + title: "Nachricht behalten" + description: "Markierung zustimmen und die Nachricht unverändert beibehalten." + agree_and_keep_deleted: + title: "Nachricht gelöscht lassen" + description: "Markierung zustimmen und die Nachricht gelöscht lassen." + agree_and_suspend: + title: "Benutzer sperren" + description: "Markierung zustimmen und den Benutzer sperren." + agree_and_silence: + title: "Benutzer stummschalten" + description: "Markierung zustimmen und den Benutzer stummschalten." + agree_and_restore: + title: "Nachricht wiederherstellen" + description: "Nachricht wiederherstellen, damit Benutzer sie sehen können." + agree_and_delete: + title: "Nachricht löschen" + description: "Nachricht löschen, damit Benutzer sie nicht sehen können." + delete_and_agree: + title: "Nachricht löschen" + disagree_and_restore: + title: "Ablehnen und Nachricht wiederherstellen" + description: "Nachricht wiederherstellen, damit alle Benutzer sie sehen können." + disagree: + title: "Ablehnen" + ignore: + title: "Ignorieren" + personal_chat: "persönlicher Chat" + discourse_automation: + scriptables: + send_chat_message: + title: Chat-Nachricht senden + reviewable_score_types: + needs_review: + title: "Überprüfung erforderlich" + unsubscribe: + chat_summary: + never: Niemals diff --git a/plugins/chat/config/locales/server.el.yml b/plugins/chat/config/locales/server.el.yml new file mode 100644 index 00000000000..d872d0ecc40 --- /dev/null +++ b/plugins/chat/config/locales/server.el.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml new file mode 100644 index 00000000000..72d651f2ab2 --- /dev/null +++ b/plugins/chat/config/locales/server.en.yml @@ -0,0 +1,191 @@ +en: + site_settings: + chat_enabled: "Enable the chat plugin." + chat_allowed_groups: "Users in these groups can chat. Note that staff can always access chat." + chat_channel_retention_days: "Chat messages in regular channels will be retained for this many days. Set to '0' to retain messages forever." + chat_dm_retention_days: "Chat messages in personal chat channels will be retained for this many days. Set to '0' to retain messages forever." + chat_auto_silence_duration: "Number of minutes that users will be silenced for when they exceed the chat message creation rate limit. Set to '0' to disable auto-silencing." + chat_allowed_messages_for_trust_level_0: "Number of messages that trust level 0 users is allowed to send in 30 seconds. Set to '0' to disable limit." + chat_allowed_messages_for_other_trust_levels: "Number of messages that users with trust levels 1-4 is allowed to send in 30 seconds. Set to '0' to disable limit." + chat_silence_user_sensitivity: "The likelihood that a user flagged in chat will be automatically silenced." + chat_auto_silence_from_flags_duration: "Number of minutes that users will be silenced for when they are automatically silenced due to flagged chat messages." + chat_default_channel_id: "The chat channel that will be opened by default when a user has no unread messages or mentions in other channels." + chat_duplicate_message_sensitivity: "The likelihood that a duplicate message by the same sender will be blocked in a short period. Decimal number between 0 and 1.0, with 1.0 being the highest setting (blocks messages more frequently in a shorter amount of time). Set to `0` to allow duplicate messages." + chat_minimum_message_length: "Minimum number of characters for a chat message." + chat_allow_uploads: "Allow uploads in public chat channels and direct message channels." + chat_archive_destination_topic_status: "The status that the destination topic should be once a channel archive is completed. This only applies when the destination topic is a new topic, not an existing one." + default_emoji_reactions: "Default emoji reactions for chat messages. Add up to 5 emojis for quick reaction." + direct_message_enabled_groups: "Allow users within these groups to create user-to-user Personal Chats. Note: staff can always create Personal Chats, and users will be able to reply to Personal Chats initiated by users who have permission to create them." + chat_message_flag_allowed_groups: "Users in these groups are allowed to flag chat messages." + errors: + chat_default_channel: "The default chat channel must be a public channel." + direct_message_enabled_groups_invalid: "You must specify at least one group for this setting. If you do not want anyone except staff to send direct messages, choose the staff group." + chat_upload_not_allowed_secure_uploads: "Chat uploads are not allowed when secure uploads site setting is enabled." + system_messages: + chat_channel_archive_complete: + title: "Chat Channel Archive Complete" + subject_template: "Chat channel archive completed successfully" + text_body_template: | + Archiving the chat channel **\#%{channel_name}** has been completed successfully. The messages were copied into the topic [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Chat Channel Archive Failed" + subject_template: "Chat channel archive failed" + text_body_template: | + Archiving the chat channel **\#%{channel_name}** has failed. %{messages_archived} messages have been archived. Partially archived messages were copied into the topic [%{topic_title}](%{topic_url}). Visit the channel at %{channel_url} to retry. + + chat: + deleted_chat_username: deleted + errors: + channel_exists_for_category: "A channel already exists for this category and name" + channel_new_message_disallowed: "The channel is %{status}, no new messages can be sent" + channel_modify_message_disallowed: "The channel is %{status}, no messages can be edited or deleted" + user_cannot_send_message: "You cannot send messages at this time." + rate_limit_exceeded: "Exceeded the limit of chat messages that can be sent within 30 seconds" + auto_silence_from_flags: "Chat message flagged with score high enough to silence user." + channel_cannot_be_archived: "The channel cannot be archived at this time, it must be either closed or open to archive." + duplicate_message: "You posted an identical message too recently." + delete_channel_failed: "Delete channel failed, please try again." + minimum_length_not_met: "Message is too short, must have a minimum of %{minimum} characters." + max_reactions_limit_reached: "New reactions are not allowed on this message." + message_move_invalid_channel: "The source and destination channel must be public channels." + message_move_no_messages_found: "No messages were found with the provided message IDs." + cant_update_direct_message_channel: "Direct message channel properties like name and description can’t be updated." + not_accepting_dms: "Sorry, %{username} is not accepting messages at the moment." + actor_ignoring_target_user: "You are ignoring %{username}, so you cannot send messages to them." + actor_muting_target_user: "You are muting %{username}, so you cannot send messages to them." + actor_disallowed_dms: "You have chosen to prevent users from sending you private and direct messages, so you cannot create new direct messages." + actor_preventing_target_user_from_dm: "You have chosen to prevent %{username} from sending you private and direct messages, so you cannot create new direct messages to them." + user_cannot_send_direct_messages: "Sorry, you cannot send direct messages." + reviewables: + message_already_handled: "Thanks, but we've already reviewed this message and determined it does not need to be flagged again." + actions: + agree: + title: "Agree..." + agree_and_keep_message: + title: "Keep Message" + description: "Agree with flag and keep the message unchanged." + agree_and_keep_deleted: + title: "Keep Message Deleted" + description: "Agree with flag and leave the message deleted." + agree_and_suspend: + title: "Suspend User" + description: "Agree with flag and suspend the user." + agree_and_silence: + title: "Silence User" + description: "Agree with flag and silence the user." + agree_and_restore: + title: "Restore Message" + description: "Restore the message so that users can see it." + agree_and_delete: + title: "Delete Message" + description: "Delete the message so that users cannot see it." + delete_and_agree: + title: "Delete Message" + disagree_and_restore: + title: "Disagree and Restore Message" + description: "Restore the message so that all users can see it." + disagree: + title: "Disagree" + ignore: + title: "Ignore" + direct_messages: + transcript_title: "Transcript of previous messages in %{channel_name}" + transcript_body: "To give you more context, we included a transcript of the previous messages in this conversation (up to ten):\n\n%{transcript}" + channel: + statuses: + read_only: "Read Only" + archived: "Archived" + closed: "Closed" + open: "Open" + archive: + first_post_raw: "This topic is an archive of the [%{channel_name}](%{channel_url}) chat channel." + messages_moved: + one: "@%{acting_username} moved a message to the [%{channel_name}](%{first_moved_message_url}) channel." + other: "@%{acting_username} moved %{count} messages to the [%{channel_name}](%{first_moved_message_url}) channel." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} and %{leftover} others" + + bookmarkable: + notification_title: "message in %{channel_name}" + + personal_chat: "personal chat" + + onebox: + inline_to_message: "Message #%{message_id} by %{username} – #%{chat_channel}" + inline_to_channel: "Chat #%{chat_channel}" + inline_to_topic_channel: "Chat for Topic %{topic_title}" + + x_members: + one: "%{count} member" + other: "%{count} members" + + and_x_others: + one: "and %{count} other" + other: "and %{count} others" + + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mentioned you in "%{channel}"' + other_type: '%{username} mentioned %{identifier} in "%{channel}"' + direct_message_chat_mention: + direct: "%{username} mentioned you in personal chat" + other_type: "%{username} mentioned %{identifier} in personal chat" + new_chat_message: '%{username} sent a message in "%{channel}"' + new_direct_chat_message: "%{username} sent a message in personal chat" + + discourse_automation: + scriptables: + send_chat_message: + title: Send chat message + + reviewable_score_types: + needs_review: + title: "Needs Review" + notify_user: + chat_pm_title: 'Your chat message in "%{channel_name}"' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'A chat message in "%{channel_name}" requires staff attention' + chat_pm_body: "%{link}\n\n%{message}" + + reviewables: + reasons: + chat_message_queued_by_staff: "A staff member thinks this chat message needs review." + user_notifications: + chat_summary: + deleted_user: "Deleted user" + description: + one: "You have a new chat message" + other: "You have new chat messages" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] New message from %{message_title}" + other: "[%{email_prefix}] New messages from %{message_title} and %{others}" + chat_channel: + one: "[%{email_prefix}] New message in %{message_title}" + other: "[%{email_prefix}] New messages in %{message_title} and %{others}" + other_direct_message: "from %{message_title}" + others: "%{count} others" + unsubscribe: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}, or %{unsubscribe_link} to unsubscribe." + unsubscribe_no_link: "This chat summary is sent from %{site_link} when you are away. Change your %{email_preferences_link}." + view_messages: + one: "View message" + other: "View %{count} messages" + view_more: + one: "View %{count} more message" + other: "View %{count} more messages" + your_chat_settings: "chat email frequency preference" + + unsubscribe: + chat_summary: + select_title: "Set chat summary emails frequency to:" + never: Never + when_away: Only when away + + category: + cannot_delete: + has_chat_channels: "Can't delete this category because it has chat channels." diff --git a/plugins/chat/config/locales/server.en_GB.yml b/plugins/chat/config/locales/server.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/chat/config/locales/server.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/chat/config/locales/server.es.yml b/plugins/chat/config/locales/server.es.yml new file mode 100644 index 00000000000..aa54c5342f8 --- /dev/null +++ b/plugins/chat/config/locales/server.es.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + chat: + errors: + channel_exists_for_category: "Ya existe un canal para esta categoría y nombre" + reviewables: + actions: + agree: + title: "De acuerdo..." + agree_and_keep_message: + title: "Conservar mensaje" + description: "Aceptar la denuncia y conservar el mensaje sin cambios." + agree_and_keep_deleted: + title: "Conservar el mensaje eliminado" + description: "Aceptar la denuncia y dejar el mensaje eliminado." + agree_and_suspend: + title: "Suspender al usuario" + description: "Aceptar la denuncia y suspender al usuario." + agree_and_silence: + title: "Silenciar al usuario" + description: "Aceptar la denuncia y silenciar al usuario." + agree_and_restore: + title: "Restaurar mensaje" + description: "Restaura el mensaje para que los usuarios puedan verlo." + agree_and_delete: + title: "Eliminar mensaje" + description: "Elimina el mensaje para que los usuarios no puedan verlo." + delete_and_agree: + title: "Eliminar mensaje" + disagree_and_restore: + title: "No aceptar y restaurar el mensaje" + description: "Restaura el mensaje para que todos los usuarios puedan verlo." + disagree: + title: "No estoy de acuerdo" + ignore: + title: "Ignorar" + personal_chat: "chat personal" + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensaje de chat + reviewable_score_types: + needs_review: + title: "Necesita revisión" + unsubscribe: + chat_summary: + never: Nunca diff --git a/plugins/chat/config/locales/server.et.yml b/plugins/chat/config/locales/server.et.yml new file mode 100644 index 00000000000..0ea0b6d554b --- /dev/null +++ b/plugins/chat/config/locales/server.et.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: diff --git a/plugins/chat/config/locales/server.fa_IR.yml b/plugins/chat/config/locales/server.fa_IR.yml new file mode 100644 index 00000000000..58a9fdc0885 --- /dev/null +++ b/plugins/chat/config/locales/server.fa_IR.yml @@ -0,0 +1,106 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + site_settings: + chat_enabled: "فعال کردن افزونه discourse-chat" + chat_allowed_groups: "کاربران در این گروه‌ها می‌توانند گفتگو کنند. توجه داشته باشید که کارکنان همیشه می‌توانند به گفتگو دسترسی داشته باشند." + chat_allow_uploads: "بارگذاری در کانال‌های گفتگو عمومی و کانال‌های پیام مستقیم مجاز است." + default_emoji_reactions: "واکنش‌های شکلک پیش‌فرض برای پیام‌های گفتگو. برای واکنش سریع تا ۵ شکلک اضافه کنید." + chat_message_flag_allowed_groups: "کاربران در این گروه‌ها مجاز به گزارش دادن، پیام‌های گفتگو هستند." + errors: + chat_upload_not_allowed_secure_uploads: "وقتی که در تنظیمات سایت آپلودهای ایمن فعال باشد، آپلود گفتگو مجاز نیست." + chat: + deleted_chat_username: حذف شده + errors: + channel_exists_for_category: "یک کانال دیگر از قبل برای این دسته‌بندی و نام وجود دارد" + cant_update_direct_message_channel: "ویژگی پیام مستقیم کانال مانند نام و توضیحات را نمی‌توان به‌روز کرد." + not_accepting_dms: "متاسفیم، %{username} در حال حاضر پیامی را نمی‌پذیرد." + actor_ignoring_target_user: "شما در حال نادیده گرفتن %{username} هستید، بنابراین نمی‌توانید پیامی را برای او ارسال کنید." + actor_muting_target_user: "شما در حال بی‌صدا کردن %{username} هستید، بنابراین نمی‌توانید پیامی را برای آنها ارسال کنید." + actor_disallowed_dms: "شما انتخاب کرده‌اید که از ارسال پیام‌های خصوصی و پیام‌های مستقیم در گفتگو توسط کاربران دیگر به شما جلوگیری کنیم، بنابراین نمی‌توانید پیام‌های مستقیم جدید در گفتگو ارسال کنید." + actor_preventing_target_user_from_dm: "شما انتخاب کرده‌اید که %{username}، از ارسال پیام‌های خصوصی و پیام‌های مستقیم در گفتگو برای شما جلوگیری کنیم، بنابراین نمی‌توانید پیام مستقیم جدیدی در گفتگو برای او ارسال کنید." + reviewables: + message_already_handled: "با تشکر از شما، اما ما در حال حاضر این پیام را بررسی کرده‌ایم و تشخیص داده‌ایم که نیازی به گزارش دوباره ندارد." + actions: + agree: + title: "موافقم..." + agree_and_delete: + title: "حذف پیام" + description: "پیام را حذف کنید تا کاربران نتوانند آن را ببینند." + delete_and_agree: + title: "حذف پیام" + disagree_and_restore: + title: "مخالفت و بازگرداندن پیام" + description: "پیام را بازیابی کنید تا همه کاربران بتوانند آن را ببینند." + disagree: + title: "مخالفم" + ignore: + title: "نادیده گرفتن" + direct_messages: + transcript_title: "رونوشت پیام‌های قبلی در %{channel_name}" + transcript_body: "برای ارائه متن بیشتر به شما، رونوشتی از پیام‌های قبلی را در این گفتگو (حداکثر ده مورد) قرار دادیم:\n\n%{transcript}" + channel: + statuses: + read_only: "فقط خواندنی" + archived: "بایگانی شده" + closed: "بسته شد" + open: "باز کردن" + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} و %{leftover} نفر دیگر" + bookmarkable: + notification_title: "پیام در %{channel_name}" + personal_chat: "گفتگوی شخصی" + onebox: + inline_to_channel: "گفتگو #%{chat_channel}" + inline_to_topic_channel: "گفتگو برای موضوع %{topic_title}" + x_members: + one: "%{count} عضو" + other: "%{count} عضو" + and_x_others: + one: "و %{count} نفر دیگر" + other: "و %{count} نفر دیگر" + discourse_automation: + scriptables: + send_chat_message: + title: ارسال پیام + reviewable_score_types: + needs_review: + title: "نیاز به بررسی دارد" + notify_user: + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_body: "%{link}\n\n%{message}" + user_notifications: + chat_summary: + deleted_user: "کاربر حذف شده" + description: + one: "شما یک پیام گفتگو جدیدی دارید" + other: "شما پیام‌های گفتگو جدیدی دارید" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] پیام جدید از %{message_title}" + other: "[%{email_prefix}] پیام جدید %{message_title} و %{others}" + chat_channel: + one: "[%{email_prefix}] پیام جدید در %{message_title}" + other: "[%{email_prefix}] پیام جدید در %{message_title} و %{others}" + other_direct_message: "از %{message_title}" + others: "%{count} نفر دیگر" + view_messages: + one: "مشاهده پیام" + other: "مشاهده %{count} پیام" + view_more: + one: "مشاهده %{count} پیام بیشتر" + other: "مشاهده %{count} پیام بیشتر" + unsubscribe: + chat_summary: + never: هرگز + category: + cannot_delete: + has_chat_channels: "نمی‌توان این دسته‌بندی را حذف کرد، چون دارای کانال‌های گفتگو است" diff --git a/plugins/chat/config/locales/server.fi.yml b/plugins/chat/config/locales/server.fi.yml new file mode 100644 index 00000000000..be24a0ccca0 --- /dev/null +++ b/plugins/chat/config/locales/server.fi.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + chat: + errors: + channel_exists_for_category: "Tällä alueella ja nimellä on jo olemassa kanava" + reviewables: + actions: + agree: + title: "Hyväksy..." + agree_and_keep_message: + title: "Säilytä viesti" + description: "Hyväksy liputus ja pidä viesti ennallaan." + agree_and_keep_deleted: + title: "Pidä viesti poistettuna" + description: "Hyväksy liputus ja pidä viesti poistettuna." + agree_and_suspend: + title: "Aseta käyttäjä käyttökieltoon" + description: "Hyväksy liputus ja aseta käyttäjä käyttökieltoon." + agree_and_silence: + title: "Hiljennä käyttäjä" + description: "Hyväksy liputus ja hiljennä käyttäjä." + agree_and_restore: + title: "Palauta viesti" + description: "Palauta viesti, jotta käyttäjät näkevät sen." + agree_and_delete: + title: "Poista viesti" + description: "Poista viesti, jotta käyttäjät eivät näe sitä." + delete_and_agree: + title: "Poista viesti" + disagree_and_restore: + title: "Hylkää ja palauta viesti" + description: "Palauta viesti, jotta kaikki käyttäjät näkevät sen." + disagree: + title: "Hylkää" + ignore: + title: "Ohita" + personal_chat: "henkilökohtainen chat" + discourse_automation: + scriptables: + send_chat_message: + title: Lähetä chat-viesti + reviewable_score_types: + needs_review: + title: "Vaatii käsittelyä" + unsubscribe: + chat_summary: + never: Ei koskaan diff --git a/plugins/chat/config/locales/server.fr.yml b/plugins/chat/config/locales/server.fr.yml new file mode 100644 index 00000000000..d4b6a8d7116 --- /dev/null +++ b/plugins/chat/config/locales/server.fr.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + chat: + errors: + channel_exists_for_category: "Un canal existe déjà pour cette catégorie et ce nom" + reviewables: + actions: + agree: + title: "D'accord…" + agree_and_keep_message: + title: "Conserver le message" + description: "Accepter le signalement et garder le message inchangé." + agree_and_keep_deleted: + title: "Garder le message supprimé" + description: "Accepter le signalement et laisser le message supprimé." + agree_and_suspend: + title: "Suspendre l'utilisateur" + description: "Accepter le signalement et suspendre l'utilisateur." + agree_and_silence: + title: "Désactiver l'utilisateur" + description: "Accepter le signalement et désactiver l'utilisateur." + agree_and_restore: + title: "Restaurer le message" + description: "Restaurer le message pour que les utilisateurs puissent le voir." + agree_and_delete: + title: "Supprimer le message" + description: "Supprimer le message pour que les utilisateurs ne puissent pas le voir." + delete_and_agree: + title: "Supprimer le message" + disagree_and_restore: + title: "Refuser et restaurer le message" + description: "Restaurer le message pour que tous les utilisateurs puissent le voir." + disagree: + title: "Refuser" + ignore: + title: "Ignorer" + personal_chat: "discussion privée" + discourse_automation: + scriptables: + send_chat_message: + title: Envoyer un message de chat + reviewable_score_types: + needs_review: + title: "Nécessite un examen" + unsubscribe: + chat_summary: + never: Jamais diff --git a/plugins/chat/config/locales/server.gl.yml b/plugins/chat/config/locales/server.gl.yml new file mode 100644 index 00000000000..fb911ce1635 --- /dev/null +++ b/plugins/chat/config/locales/server.gl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: diff --git a/plugins/chat/config/locales/server.he.yml b/plugins/chat/config/locales/server.he.yml new file mode 100644 index 00000000000..29ad08ba4f5 --- /dev/null +++ b/plugins/chat/config/locales/server.he.yml @@ -0,0 +1,201 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + site_settings: + chat_enabled: "הפעלת תוסף discourse-chat." + chat_allowed_groups: "משתמשים בקבוצות אלה יכולים לשוחח בצ׳אט. נא לשים לב שהסגל תמיד יכול לגשת לצ׳אט." + chat_channel_retention_days: "הודעות הצ׳אט בערוצים הרגילים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." + chat_dm_retention_days: "הודעות הצ׳אט בערוצי הצ׳אט האישיים ישמרו למשך כמות כזאת של ימים. הגדרה לאפס תשמור את ההודעות לעד." + chat_auto_silence_duration: "מספר הדקות שבמהלכן שמשתמשים יושתקו כאשר הם חורגים ממגבלת קצב יצירת הודעת בצ׳אט. 0 משבית את ההשתקה האוטומטית." + chat_allowed_messages_for_trust_level_0: "מספר ההודעות שמשתמשים בדרגת אמון 0 רשאים לשלוח תוך 30 שניות. ‚0’ כדי להשבית את המגבלה." + chat_allowed_messages_for_other_trust_levels: "מספר ההודעות שמשתמשים בדרגות אמון 1-4 רשאים לשלוח תוך 30 שניות. ‚0’ כדי להשבית את המגבלה." + chat_silence_user_sensitivity: "הסבירות שמשתמש שסומן בצ׳אט יושתק אוטומטית." + chat_auto_silence_from_flags_duration: "מספר דקות השתקת המשתמשים כאשר הם מושתקים אוטומטית עקב הודעות צ׳אט מסומנות." + chat_default_channel_id: "ערוץ הצ׳אט שייפתח כברירת מחדל כאשר למשתמש אין הודעות או אזכורים שלא נקראו בערוצים אחרים." + chat_duplicate_message_sensitivity: "הסבירות שהודעה כפולה מאותו שולח תיחסם תוך זמן קצר. מספר עשרוני בין 0 ל־1.0, כאשר 1.0 הוא ההגדרה הגבוהה ביותר (חוסם הודעות בתדירות גבוהה יותר בפרק זמן קצר יותר). ‚0’ כדי לאפשר הודעות כפולות." + chat_minimum_message_length: "מספר התווים המזערי להודעת צ׳אט." + chat_allow_uploads: "לאפשר העלאות בערוצי צ׳אט ציבוריים ובערוצי הודעות ישירות." + chat_archive_destination_topic_status: "המצב בו נושא היעד צריך להיות לאחר שהעברת ערוץ לארכיון הושלמה. חל רק כאשר נושא היעד הוא נושא חדש ולא קיים." + default_emoji_reactions: "רגשות אמוג׳י כברירת מחדל להודעות צ׳אט. ניתן להוסיף עד 5 אמוג׳ים לתגובה מהירה." + direct_message_enabled_groups: "לאפשר למשתמשים בקבוצות אלה ליצור צ׳אטים אישיים בין המשתמשים לבין עצמם. הערה: הסגל תמיד יכול ליצור צ׳אטים אישיים, ומשתמשים יוכלו להשיב לצ׳אטים אישיים שיזמו משתמשים שיש להם הרשאה ליצור אותם." + chat_message_flag_allowed_groups: "משתמשים בקבוצות אלו רשאים לסמן הודעות צ׳אט בדגל." + errors: + chat_default_channel: "ערוץ הצ׳אט כברירת המחדל חייב להיות ערוץ ציבורי." + direct_message_enabled_groups_invalid: "יש לציין לפחות קבוצה אחת בהגדרה הזאת. כדי למנוע מכולם לשלוח הודעות ישירות למעט הסגל, יש לבחור בקבוצת הסגל." + chat_upload_not_allowed_secure_uploads: "אסור להעלות לצ׳אט כשהגדרת האתר להעלאות מאובטחות מופעלת." + system_messages: + chat_channel_archive_complete: + title: "העברת ערוץ הצ׳אט לארכיון הושלמה" + subject_template: "העברת ערוץ הצ׳אט לארכיון הושלמה בהצלחה" + text_body_template: | + העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון הושלמה בהצלחה. ההודעות הועתקו לנושא [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "העברת הצ׳אט לארכיון נכשלה" + subject_template: "העברת הצ׳אט לארכיון נכשלה" + text_body_template: | + העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון נכשלה. %{messages_archived} הודעות הועברו לארכיון. הודעות שהועברו לארכיון באופן חלקי הועתקו לנושא [%{topic_title}](%{topic_url}). יש לבקר בכתובת הערוץ %{channel_url} כדי לנסות שוב. + chat: + deleted_chat_username: נמחק + errors: + channel_exists_for_category: "כבר קיים ערוץ לקטגוריה ולשם האלו" + channel_new_message_disallowed: "הערוץ %{status}, לא ניתן לשלוח הודעות חדשות" + channel_modify_message_disallowed: "הערוץ %{status}, לא ניתן לערוך או למחוק הודעות" + user_cannot_send_message: "אין לך אפשרות לשלוח הודעות כרגע." + rate_limit_exceeded: "חריגה ממגבלת הודעות הצ׳אט שניתן לשלוח תוך 30 שניות" + auto_silence_from_flags: "הודעת צ׳אט שסומנה בציון גבוה מספיק כדי להשתיק את המשתמש." + channel_cannot_be_archived: "אי אפשר להעביר את הערוץ לארכיון כרגע, הוא חייב להיות סגור או פתוח להעברה לארכיון." + duplicate_message: "פרסמת הודעה זהה לפני זמן קצר מדי." + delete_channel_failed: "מחיקת הערוץ נכשלה, נא לנסות שוב." + minimum_length_not_met: "ההודעה קצרה מדי, היא חייבת להיות ארוכה מ־%{minimum} תווים" + max_reactions_limit_reached: "רגשות חדשים אסורים בהודעה זו." + message_move_invalid_channel: "ערוצי המקור והיעד חייבים להיות ערוצים ציבוריים." + message_move_no_messages_found: "לא נמצאו הודעות עם מזהי ההודעות שסופקו." + cant_update_direct_message_channel: "מאפייני ערוץ הודעות ישירות כמו שם ותיאור נעולים מפני עדכון." + not_accepting_dms: "לא מתקבלות הודעות אצל %{username} כרגע, עמך הסליחה." + actor_ignoring_target_user: "בחרת להתעלם מ־%{username}, כך שאין לך אפשרות לשלוח אליהם הודעות." + actor_muting_target_user: "בחרת להשתיק את %{username}, כך שאין לך אפשרות לשלוח אליהם הודעות." + actor_disallowed_dms: "בחרת למנוע ממשתמשים לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות." + actor_preventing_target_user_from_dm: "בחרת למנוע מ־%{username} לשלוח אליך הודעות פרטיות וישירות כך שאין לך אפשרות ליצור הודעות ישירות חדשות אליהם." + user_cannot_send_direct_messages: "מחילה, אין לך אפשרות לשלוח הודעות ישירות." + reviewables: + message_already_handled: "תודה, אבל כבר סקרנו הודעה זו וקבענו שאין צורך לסמן אותה שוב." + actions: + agree: + title: "הסכמה…" + agree_and_keep_message: + title: "להשאיר את ההודעה" + description: "להסכים עם הסימון ולהשאיר את ההודעה ללא שינוי." + agree_and_keep_deleted: + title: "להשאיר את ההודעה מחוקה" + description: "להסכים עם הסימון ולהשאיר את ההודעה מחוקה." + agree_and_suspend: + title: "השעיית משתמש" + description: "להסכים עם הסימון ולהשעות את המשתמש." + agree_and_silence: + title: "השתקת משתמש" + description: "להסכים עם הסימון ולהשתיק את המשתמש." + agree_and_restore: + title: "שחזור הודעה" + description: "לשחזר את ההודעה כדי שמשתמשים יוכלו לראות אותה." + agree_and_delete: + title: "מחיקת ההודעה" + description: "למחוק את ההודעה כדי שמשתמשים לא יוכלו לראות אותה." + delete_and_agree: + title: "מחיקת ההודעה" + disagree_and_restore: + title: "חוסר הסכמה ושחזור ההודעה" + description: "לשחזר את ההודעה כדי שכל המשתמשים יוכלו לראות אותה." + disagree: + title: "אי־הסכמה" + ignore: + title: "התעלמות" + direct_messages: + transcript_title: "תמלול הודעות קודמות בערוץ %{channel_name}" + transcript_body: "כדי לתת לך יותר הקשר, הוספנו תמליל של (עד עשר) ההודעות הקודמות בשיחה זו:\n\n%{transcript}" + channel: + statuses: + read_only: "לקריאה בלבד" + archived: "בארכיון" + closed: "סגור" + open: "פתוח" + archive: + first_post_raw: "הנושא הזה הוא הארכיון של ערוץ הצ׳אט [%{channel_name}](%{channel_url})." + messages_moved: + one: "הודעה הועברה על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + two: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + many: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + other: "%{count} הודעות הועברו על ידי ‎@%{acting_username} לערוץ [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} ו־%{leftover} נוספים" + bookmarkable: + notification_title: "הודעה ב־%{channel_name}" + personal_chat: "צ׳אט אישי" + onebox: + inline_to_message: "הודעה מס׳ %{message_id} מאת ‎%{username}‏ – ‎#%{chat_channel}" + inline_to_channel: "צ׳אט מס׳ %{chat_channel}" + inline_to_topic_channel: "צ׳אט לנושא %{topic_title}" + x_members: + one: "חבר %{count}" + two: "%{count} חברים" + many: "%{count} חברים" + other: "%{count} חברים" + and_x_others: + one: "ועוד %{count}" + two: "ו־%{count} נוספים" + many: "ו־%{count} נוספים" + other: "ו־%{count} נוספים" + discourse_push_notifications: + popup: + chat_mention: + direct: 'אוזכרת בערוץ „%{channel}” על ידי %{username}' + other: 'נוסף אזכור של %{identifier} בערוץ „%{channel}” על ידי %{username}' + direct_message_chat_mention: + direct: "אוזכרת בצ׳אט אישי על ידי %{username}" + other: "נוסף אזכור של ‎%{identifier} בצ׳אט אישי על ידי %{username}" + new_chat_message: 'נשלחה הודעה על ידי %{username} ב־„%{channel}”' + new_direct_chat_message: "נשלחה הודעה על ידי %{username} בצ׳אט אישי" + discourse_automation: + scriptables: + send_chat_message: + title: שליחת הודעת צ׳אט + reviewable_score_types: + needs_review: + title: "נדרשת סקירה" + notify_user: + chat_pm_title: 'הודעת הצ׳אט שלך תחת „%{channel_name}”' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'הודעת צ׳אט בערוץ „%{channel_name}” דורשת את תשומת לב הסגל' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "חבר סגל חושב שהודעת צ׳אט זו דורשת בדיקה." + user_notifications: + chat_summary: + deleted_user: "משתמש שנמחק" + description: + one: "יש לך הודעה חדשה בצ׳אט" + two: "יש לך הודעות חדשות בצ׳אט" + many: "יש לך הודעות חדשות בצ׳אט" + other: "יש לך הודעות חדשות בצ׳אט" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] הודעה חדשה מאת %{message_title}" + two: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + many: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + other: "[%{email_prefix}] הודעה חדשה מאת %{message_title} ועוד %{others}" + chat_channel: + one: "[%{email_prefix}] הודעה חדשה תחת %{message_title}" + two: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + many: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + other: "[%{email_prefix}] הודעה חדשה תחת %{message_title} ועוד %{others}" + other_direct_message: "מאת %{message_title}" + others: "%{count} נוספים" + unsubscribe: "סיכום צ׳אט זה נשלח מהאתר %{site_link} כשנעדרת ממנו. ניתן לשנות את %{email_preferences_link} שלך או %{unsubscribe_link} כדי להפסיק לקבל הודעות." + unsubscribe_no_link: "סיכום צ׳אט זה נשלח מהאתר %{site_link} כשנעדרת ממנו. ניתן לשנות את %{email_preferences_link} שלך." + view_messages: + one: "הצגת הודעה" + two: "הצגת %{count} הודעות" + many: "הצגת %{count} הודעות" + other: "הצגת %{count} הודעות" + view_more: + one: "הצגת הודעה נוספת %{count}" + two: "הצגת %{count} הודעות נוספות" + many: "הצגת %{count} הודעות נוספות" + other: "הצגת %{count} הודעות נוספות" + your_chat_settings: "העדפת תדירות דוא״ל צ׳אט" + unsubscribe: + chat_summary: + select_title: "הגדרת תדירות הודעות סיכום בדוא״ל ל־:" + never: לעולם לא + when_away: רק כשלא במערכת + category: + cannot_delete: + has_chat_channels: "לא ניתן למחוק את הקטגוריה הזו כי יש לה ערוצי צ׳אט." diff --git a/plugins/chat/config/locales/server.hr.yml b/plugins/chat/config/locales/server.hr.yml new file mode 100644 index 00000000000..93343ce6e19 --- /dev/null +++ b/plugins/chat/config/locales/server.hr.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: diff --git a/plugins/chat/config/locales/server.hu.yml b/plugins/chat/config/locales/server.hu.yml new file mode 100644 index 00000000000..7b154062fff --- /dev/null +++ b/plugins/chat/config/locales/server.hu.yml @@ -0,0 +1,147 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + site_settings: + chat_channel_retention_days: "A normál csatornákon lévő csevegőüzenetek ennyi napig maradnak meg. Állítsa „0”-ra, hogy örökre megtartsa az üzeneteket." + chat_dm_retention_days: "A személyes csevegési csatornákon lévő csevegőüzenetek ennyi napig maradnak meg. Állítsa „0”-ra, hogy örökre megtartsa az üzeneteket." + chat_auto_silence_duration: "A felhasználók ennyi percig lesznek némítva, ha túllépik a csevegőüzenet létrehozási korlátját. Állítsa „0”-ra, hogy letiltsa az automatikus némítást." + chat_allowed_messages_for_trust_level_0: "A 0-s megbízhatósági szinttel rendelkező felhasználók legfeljebb ennyi üzenetet küldhetnek 30 másodpercen belül. A korlátozás letiltásához állítsa „0”-ra." + chat_allowed_messages_for_other_trust_levels: "Az 1–4-es megbízhatósági szinttel rendelkező felhasználók legfeljebb ennyi üzenetet küldhetnek 30 másodpercen belül. A korlátozás letiltásához állítsa „0”-ra." + chat_silence_user_sensitivity: "Annak a valószínűsége, hogy a csevegésben megjelölt felhasználó automatikusan némítva lesz." + chat_auto_silence_from_flags_duration: "Azon percek száma, ameddig a felhasználók el lesznek némítva, ha a megjelölt csevegési üzenetek miatt automatikusan némítva lesznek." + chat_default_channel_id: "Az a csevegőcsatorna, amely alapértelmezés szerint megnyílik, ha a felhasználónak nincsenek olvasatlan üzenetei vagy említései más csatornákon." + chat_duplicate_message_sensitivity: "Annak a valószínűsége, hogy az azonos feladó által küldött ismételt üzenet rövid időn belül blokkolásra kerül. Tizedes szám 0 és 1.0 között, ahol az 1.0 a legmagasabb érték (az üzeneteket gyakrabban blokkolja rövidebb idő alatt). Az ismételt üzenetek engedélyezéséhez állítsa „0”-ra az értéket." + chat_minimum_message_length: "A csevegőüzenetek minimális karakterszáma." + chat_archive_destination_topic_status: "Az az állapot, amelyet a céltéma a csatornaarchiválás befejezése után fog kapni. Ez csak akkor érvényes, ha a céltéma egy új téma, nem pedig egy meglévő." + errors: + chat_default_channel: "Az alapértelmezett csevegőcsatornának nyilvános csatornának kell lennie." + system_messages: + chat_channel_archive_complete: + title: "A csevegőcsatorna archiválása kész" + subject_template: "A csevegőcsatorna archiválása sikeresen befejeződött" + text_body_template: | + A(z) **\#%{channel_name}** csevegőcsatorna archiválása sikeresen befejeződött. Az üzenetek átmásolásra kerültek a(z) [ <%{topic_title}](%{topic_url}) témába. + chat_channel_archive_failed: + title: "A csevegőcsatorna archiválása sikertelen" + subject_template: "A csevegőcsatorna archiválása sikertelen" + text_body_template: | + A(z) **\#%{channel_name}** csevegőcsatorna archiválása nem sikerült. %{messages_archived} üzenet archiválásra került. A részben archivált üzeneteket a(z) [%{topic_title}](%{topic_url}) témába lettek másolva. Keresse fel a(z) %{channel_url} csatornát az újbóli próbálkozáshoz. + chat: + deleted_chat_username: törölve + errors: + channel_exists_for_category: "Már létezik csatorna ehhez a kategóriával, és ezzel a névvel" + channel_new_message_disallowed: "A csatorna „%{status}”, új üzenet nem küldhető" + channel_modify_message_disallowed: "A csatorna „%{status}”, az üzenetek nem szerkeszthetők vagy törölhetők" + user_cannot_send_message: "Jelenleg nem küldhet üzeneteket." + rate_limit_exceeded: "Túllépte a 30 másodpercen belül elküldhető csevegőüzenetek korlátját" + auto_silence_from_flags: "A csevegőüzenet elég magas pontszámmal lett megjelölve, hogy a felhasználó némítva legyen." + channel_cannot_be_archived: "A csatorna jelenleg nem archiválható, a csatornát vagy le kell zárni, vagy meg kell nyitni az archiváláshoz." + duplicate_message: "Nemrég küldött egy azonos tartalmú üzenetet." + delete_channel_failed: "A csatorna törlése sikertelen, próbálja meg újra." + minimum_length_not_met: "Az üzenet túl rövid, legalább %{minimum} karaktert kell tartalmaznia." + max_reactions_limit_reached: "Új reakciók nem engedélyezettek ezen az üzeneten." + message_move_invalid_channel: "A forrás- és célcsatornának nyilvános csatornának kell lennie." + message_move_no_messages_found: "A megadott üzenetazonosítókkal nem találhatók üzenetek." + reviewables: + actions: + agree: + title: "Egyetértek…" + agree_and_keep_message: + title: "Üzenet megtartása" + description: "Egyetért a jelentéssel, és változatlanul hagyja az üzenetet." + agree_and_keep_deleted: + title: "Üzenet törölve hagyása" + description: "Egyetért a jelentéssel, és törölve hagyja az üzenetet." + agree_and_suspend: + title: "Felhasználó felfüggesztése" + description: "Egyetért a jelentéssel, és felfüggeszti a felhasználót." + agree_and_silence: + title: "Felhasználó némítása" + description: "Egyetért a jelentéssel, és némítja a felhasználót." + agree_and_restore: + title: "Üzenet helyreállítása" + description: "Üzenet helyreállítása, hogy láthassák a felhasználók." + agree_and_delete: + title: "Üzenet törlése" + description: "Üzenet törlése, hogy a felhasználók ne láthassák." + delete_and_agree: + title: "Üzenet törlése" + disagree_and_restore: + title: "Nem ért egyet, és az üzenet helyreállítása" + description: "Üzenet helyreállítása, hogy az összes felhasználó láthassa." + disagree: + title: "Nem ért egyet" + ignore: + title: "Figyelmen kívül hagyás" + channel: + statuses: + read_only: "Csak olvasható" + archived: "Archivált" + closed: "Zárolt" + open: "Nyitott" + archive: + first_post_raw: "Ez a téma a(z) [%{channel_name}](%{channel_url}) csevegőcsatorna archívuma." + messages_moved: + one: "@%{acting_username} áthelyezett egy üzenetet a(z) [%{channel_name}](%{first_moved_message_url}) csatornába." + other: "@%{acting_username} áthelyezett %{count} üzenetet a(z) [%{channel_name}](%{first_moved_message_url}) csatornába." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} és még %{leftover} fő" + personal_chat: "személyes csevegés" + onebox: + x_members: + one: "%{count} tag" + other: "%{count} tag" + and_x_others: + one: "és még %{count} fő" + other: "és még %{count} fő" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} megemlítette Önt a következő csatornán: „%{channel}”' + other: '%{username} megemlítette %{identifier} felhasználót a következő csatornán: „%{channel}”' + direct_message_chat_mention: + direct: "%{username} megemlítette Önt egy személyes csevegésben" + other: "%{username} megemlítette %{identifier} felhasználót egy személyes csevegésben" + new_chat_message: '%{username} üzenet küldött a következő csatornán: „%{channel}”' + new_direct_chat_message: "%{username} üzenetet küldött egy személyes csevegésben" + discourse_automation: + scriptables: + send_chat_message: + title: Csevegőüzenet küldése + reviewable_score_types: + needs_review: + title: "Felülvizsgálatra szorul" + user_notifications: + chat_summary: + deleted_user: "Törölt felhasználó" + description: + one: "Új csevegőüzenete érkezett" + other: "Új csevegőüzenetei érkeztek" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Új üzenet a következőtől: %{message_title}" + other: "[%{email_prefix}] Új üzenetek a következőktől: %{message_title} és %{others}" + other_direct_message: "a következőtől: %{message_title}" + others: "és még %{count} fő" + view_messages: + one: "Üzenet megtekintése" + other: "%{count} üzenet megtekintése" + view_more: + one: "%{count} további üzenet megtekintése" + other: "%{count} további üzenet megtekintése" + your_chat_settings: "csevegési e-mail gyakoriságának beállítása" + unsubscribe: + chat_summary: + select_title: "A csevegési összefoglaló e-mailek gyakoriságának beállítása:" + never: Soha + when_away: Csak ha távol van + category: + cannot_delete: + has_chat_channels: "Ezt a kategóriát nem lehet törölni, mert csevegőcsatornái vannak." diff --git a/plugins/chat/config/locales/server.hy.yml b/plugins/chat/config/locales/server.hy.yml new file mode 100644 index 00000000000..cb18f64d356 --- /dev/null +++ b/plugins/chat/config/locales/server.hy.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: diff --git a/plugins/chat/config/locales/server.id.yml b/plugins/chat/config/locales/server.id.yml new file mode 100644 index 00000000000..596e36b2e13 --- /dev/null +++ b/plugins/chat/config/locales/server.id.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: diff --git a/plugins/chat/config/locales/server.it.yml b/plugins/chat/config/locales/server.it.yml new file mode 100644 index 00000000000..5e6576b3566 --- /dev/null +++ b/plugins/chat/config/locales/server.it.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + chat: + errors: + channel_exists_for_category: "Esiste già un canale per questa categoria e questo nome" + reviewables: + actions: + agree: + title: "Accetta..." + agree_and_keep_message: + title: "Conserva messaggio" + description: "Accetta segnalazione e mantieni il messaggio invariato." + agree_and_keep_deleted: + title: "Conferma eliminazione del messaggio" + description: "Accetta segnalazione e conferma eliminazione del messaggio." + agree_and_suspend: + title: "Sospendi utente" + description: "Accetta segnalazione e sospendi l'utente." + agree_and_silence: + title: "Silenzia utente" + description: "Accetta segnalazione e silenzia l'utente." + agree_and_restore: + title: "Ripristina messaggio" + description: "Ripristina il messaggio in modo che gli utenti possano vederlo." + agree_and_delete: + title: "Elimina messaggio" + description: "Elimina il messaggio in modo che gli utenti non possano vederlo." + delete_and_agree: + title: "Elimina messaggio" + disagree_and_restore: + title: "Rifiuta e ripristina il messaggio" + description: "Ripristina il messaggio in modo che tutti gli utenti possano vederlo." + disagree: + title: "Rifiuta" + ignore: + title: "Ignora" + personal_chat: "chat personale" + discourse_automation: + scriptables: + send_chat_message: + title: Invia messaggio di chat + reviewable_score_types: + needs_review: + title: "Necessita di revisione" + unsubscribe: + chat_summary: + never: Mai diff --git a/plugins/chat/config/locales/server.ja.yml b/plugins/chat/config/locales/server.ja.yml new file mode 100644 index 00000000000..f6222a7c988 --- /dev/null +++ b/plugins/chat/config/locales/server.ja.yml @@ -0,0 +1,52 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + chat: + errors: + channel_exists_for_category: "このカテゴリと名前のチャンネルはすでに存在します" + reviewables: + actions: + agree: + title: "同意…" + agree_and_keep_message: + title: "メッセージを維持" + description: "通報に同意し、メッセージを未変更のままにします。" + agree_and_keep_deleted: + title: "メッセージの削除を維持" + description: "通報に同意し、メッセージを削除したままにします。" + agree_and_suspend: + title: "ユーザーを凍結" + description: "通報に同意し、ユーザーを凍結します。" + agree_and_silence: + title: "ユーザーを投稿禁止" + description: "通報に同意し、ユーザーを投稿禁止にします。" + agree_and_restore: + title: "メッセージを復元" + description: "ユーザーが閲覧できるようにメッセージを復元します。" + agree_and_delete: + title: "メッセージを削除" + description: "ユーザーが閲覧できないようにメッセージを削除します。" + delete_and_agree: + title: "メッセージを削除" + disagree_and_restore: + title: "同意せずにメッセージを復元" + description: "すべてのユーザーが閲覧できるようにメッセージを復元します。" + disagree: + title: "同意しない" + ignore: + title: "無視" + personal_chat: "パーソナルチャット" + discourse_automation: + scriptables: + send_chat_message: + title: チャットメッセージを送信する + reviewable_score_types: + needs_review: + title: "要レビュー" + unsubscribe: + chat_summary: + never: なし diff --git a/plugins/chat/config/locales/server.ko.yml b/plugins/chat/config/locales/server.ko.yml new file mode 100644 index 00000000000..18dd77fd3ec --- /dev/null +++ b/plugins/chat/config/locales/server.ko.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: diff --git a/plugins/chat/config/locales/server.lt.yml b/plugins/chat/config/locales/server.lt.yml new file mode 100644 index 00000000000..16bb19758dc --- /dev/null +++ b/plugins/chat/config/locales/server.lt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: diff --git a/plugins/chat/config/locales/server.lv.yml b/plugins/chat/config/locales/server.lv.yml new file mode 100644 index 00000000000..59e0ef6f4ed --- /dev/null +++ b/plugins/chat/config/locales/server.lv.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: diff --git a/plugins/chat/config/locales/server.nb_NO.yml b/plugins/chat/config/locales/server.nb_NO.yml new file mode 100644 index 00000000000..2e2224d1472 --- /dev/null +++ b/plugins/chat/config/locales/server.nb_NO.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: diff --git a/plugins/chat/config/locales/server.nl.yml b/plugins/chat/config/locales/server.nl.yml new file mode 100644 index 00000000000..2096bfe4865 --- /dev/null +++ b/plugins/chat/config/locales/server.nl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: diff --git a/plugins/chat/config/locales/server.pl_PL.yml b/plugins/chat/config/locales/server.pl_PL.yml new file mode 100644 index 00000000000..0099c6edb25 --- /dev/null +++ b/plugins/chat/config/locales/server.pl_PL.yml @@ -0,0 +1,54 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + chat: + errors: + channel_exists_for_category: "Istnieje już kanał dla tej kategorii i nazwy" + user_cannot_send_message: "W tej chwili nie możesz wysyłać wiadomości." + rate_limit_exceeded: "Przekroczono limit wiadomości na czacie, które można wysłać w ciągu 30 sekund" + max_reactions_limit_reached: "Nowe reakcje nie są dozwolone w tej wiadomości." + reviewables: + actions: + agree_and_keep_message: + title: "Zachowaj wiadomość" + agree_and_keep_deleted: + title: "Zachowaj wiadomość usuniętą" + agree_and_suspend: + title: "Zawieś użytkownika" + agree_and_silence: + title: "Wycisz użytkownika" + description: "Zgódź się z flagą i wycisz użytkownika." + agree_and_restore: + title: "Przywróć wiadomość" + agree_and_delete: + title: "Usuń wiadomość" + delete_and_agree: + title: "Usuń wiadomość" + disagree: + title: "Nie zgadzam się" + ignore: + title: "Ignoruj" + channel: + statuses: + read_only: "Tylko do odczytu" + archived: "Zarchiwizowany" + closed: "Zamknięty" + open: "Otwarty" + personal_chat: "czat osobisty" + discourse_automation: + scriptables: + send_chat_message: + title: Wyślij wiadomość na czacie + reviewable_score_types: + needs_review: + title: "Wymaga przeglądu" + unsubscribe: + chat_summary: + never: Nigdy + category: + cannot_delete: + has_chat_channels: "Nie można usunąć tej kategorii, ponieważ zawiera ona kanały czatu." diff --git a/plugins/chat/config/locales/server.pt.yml b/plugins/chat/config/locales/server.pt.yml new file mode 100644 index 00000000000..298ba523c1d --- /dev/null +++ b/plugins/chat/config/locales/server.pt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: diff --git a/plugins/chat/config/locales/server.pt_BR.yml b/plugins/chat/config/locales/server.pt_BR.yml new file mode 100644 index 00000000000..04eff33d595 --- /dev/null +++ b/plugins/chat/config/locales/server.pt_BR.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + site_settings: + chat_default_channel_id: "O canal de chat que será aberto por padrão quando um usuário não tiver mensagens não lidas ou menções em outros canais." + chat: + errors: + channel_exists_for_category: "Já existe um canal para este nome e categoria" + channel_cannot_be_archived: "O canal não pode ser arquivado no momento, ele deve estar fechado ou aberto para arquivar." + duplicate_message: "Você postou uma mensagem idêntica muito recentemente." + minimum_length_not_met: "A mensagem é muito curta, deve ter no mínimo %{minimum} caracteres." + not_accepting_dms: "Desculpe, %{username} não está aceitando mensagens no momento." + actor_ignoring_target_user: "Você está ignorando %{username}, então você não pode enviar mensagens para ele(a)." + actor_muting_target_user: "Você está silenciando %{username}, então você não pode enviar mensagens para ele(a)." + actor_disallowed_dms: "Você optou por impedir que os usuários lhe enviem mensagens privadas e diretas, portanto, você não pode criar novas mensagens diretas." + actor_preventing_target_user_from_dm: "Você optou por impedir que %{username} lhe envie mensagens privadas e diretas, portanto, você não pode criar novas mensagens diretas para ele(a)." + reviewables: + actions: + agree: + title: "Concordo..." + agree_and_keep_message: + title: "Manter mensagem" + description: "Concorde com a sinalização e mantenha a mensagem inalterada." + agree_and_keep_deleted: + title: "Manter mensagem excluída" + description: "Concorde com a sinalização e mantenha a mensagem excluída." + agree_and_suspend: + title: "Suspender usuário(s)" + description: "Concorde com a sinalização e suspenda o usuário(a)." + agree_and_silence: + title: "Silenciar usuário(a)" + description: "Concorde com a sinalização e silencie o usuário(a)." + agree_and_restore: + title: "Restaurar mensagem" + description: "Restaure a mensagem para que os(as) usuários(as) possam vê-las." + agree_and_delete: + title: "Excluir mensagem" + description: "Exclua a mensagem para que os(as) usuários(as) não possam vê-las." + delete_and_agree: + title: "Excluir mensagem" + disagree_and_restore: + title: "Não concordar e restaurar mensagem" + description: "Restaure a mensagem para que todos(as) os(as) usuários(as) possam vê-las." + disagree: + title: "Discordar" + ignore: + title: "Ignorar" + channel: + statuses: + archived: "Arquivado" + closed: "Fechado" + open: "Aberto" + bookmarkable: + notification_title: "mensagem em %{channel_name}" + personal_chat: "chat pessoal" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} mencionou você em "%{channel}"' + other: '%{username} mencionou %{identifier} em "%{channel}"' + new_chat_message: '%{username} enviou uma mensagem em "%{channel}"' + discourse_automation: + scriptables: + send_chat_message: + title: Enviar mensagem de chat + reviewable_score_types: + needs_review: + title: "Precisa de revisão" + user_notifications: + chat_summary: + deleted_user: "Usuário excluído" + from: "%{site_name}" + subject: + other_direct_message: "de %{message_title}" + others: "outros %{count}" + unsubscribe: + chat_summary: + never: Nunca + when_away: Só quando estiver ausente diff --git a/plugins/chat/config/locales/server.ro.yml b/plugins/chat/config/locales/server.ro.yml new file mode 100644 index 00000000000..08a77f812ee --- /dev/null +++ b/plugins/chat/config/locales/server.ro.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: diff --git a/plugins/chat/config/locales/server.ru.yml b/plugins/chat/config/locales/server.ru.yml new file mode 100644 index 00000000000..0c7c01c5003 --- /dev/null +++ b/plugins/chat/config/locales/server.ru.yml @@ -0,0 +1,201 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + site_settings: + chat_enabled: "Включить плагин discourse-chat ." + chat_allowed_groups: "Пользователи в этих группах могут общаться в чате. Обратите внимание, что сотрудники всегда могут получить доступ к чату." + chat_channel_retention_days: "Сообщения чата в обычных каналах будут храниться указанное здесь количество дней. Установите значение в '0', чтобы сохранять сообщения навсегда." + chat_dm_retention_days: "Сообщения чата в личных каналах чата будут храниться указанное здесь количество дней. Установите значение в '0', чтобы сохранять сообщения навсегда." + chat_auto_silence_duration: "Количество минут, в течение которых пользователи будут заблокированы, если они превысят лимит скорости создания сообщений в чате. Установите значение в '0', чтобы отключить автоблокировку." + chat_allowed_messages_for_trust_level_0: "Количество сообщений, которые пользователи с уровнем доверия '0' могут отправлять в течение 30 секунд. Установите значение в '0', чтобы отключить ограничение." + chat_allowed_messages_for_other_trust_levels: "Количество сообщений, которые пользователи с уровнем доверия от '1' до '4' могут отправлять в течение 30 секунд. Установите значение в '0', чтобы отключить ограничение." + chat_silence_user_sensitivity: "Вероятность того, что пользователь, на которого поступила жалоба, будет автоматически заблокирован." + chat_auto_silence_from_flags_duration: "Количество минут, в течение которых пользователи будут заблокированы, если на их сообщения поступают жалобы." + chat_default_channel_id: "Канал чата, который будет открываться по умолчанию, когда у пользователя нет непрочитанных сообщений или упоминаний в других каналах." + chat_duplicate_message_sensitivity: "Вероятность того, что дубликат сообщения от одного и того же отправителя будет заблокирован через короткий промежуток времени. Десятичное число от '0' до '1.0', где '1.0' — блокирует сообщения наиболее часто за короткий промежуток времени, а '0' - разрешает дублирование сообщений." + chat_minimum_message_length: "Минимальное количество символов при создании сообщения чата." + chat_allow_uploads: "Разрешить загрузку в общедоступных каналах чата и каналах прямых сообщений." + chat_archive_destination_topic_status: "Статус, который должен быть присвоен теме назначения после завершения архивирования канала. Присваивается только в том случае, если целевой темой является новая тема, а не существующая." + default_emoji_reactions: "Стандартные эмодзи чата. Можно добавить до 5 смайликов." + direct_message_enabled_groups: "Разрешить пользователям в этих группах создавать личные чаты между пользователями. Примечание. Сотрудники всегда могут создавать личные чаты, а пользователи смогут отвечать на личные чаты, инициированные пользователями, имеющими разрешение на их создание." + chat_message_flag_allowed_groups: "Пользователям этих групп разрешено жаловаться на сообщения в чате." + errors: + chat_default_channel: "Канал чата по умолчанию должен быть общедоступным." + direct_message_enabled_groups_invalid: "Для этого параметра необходимо указать хотя бы одну группу. Если вы не хотите, чтобы кто-либо, кроме сотрудников, отправлял прямые сообщения, выберите группу сотрудников." + chat_upload_not_allowed_secure_uploads: "Загрузка в чат запрещена, если включено ограничение доступа к загружаемому контенту." + system_messages: + chat_channel_archive_complete: + title: "Архивация канала завершена" + subject_template: "Архивация канала успешно завершена" + text_body_template: | + Архивация канала **\#%{channel_name}** успешно завершена. Сообщения были скопированы в тему [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Не удалось заархивировать канал" + subject_template: "Не удалось заархивировать канал" + text_body_template: | + Не удалось заархивировать канал **\#%{channel_name}**. Сообщения %{messages_archived} были заархивированы. Частично заархивированные сообщения были скопированы в тему [%{topic_title}](%{topic_url}). Посетите канал %{channel_url} и повторите попытку. + chat: + deleted_chat_username: удалён + errors: + channel_exists_for_category: "Канал для этого раздела уже существует" + channel_new_message_disallowed: "Канал %{status}, в него не могут быть отправлены новые сообщения" + channel_modify_message_disallowed: "Канал %{status}, существующие сообщения не могут быть отредактированы или удалены" + user_cannot_send_message: "В настоящее время вы не можете отправлять сообщения." + rate_limit_exceeded: "Превышен лимит сообщений, которые могут быть отправлены в течение 30 секунд" + auto_silence_from_flags: "На сообщение поступило большое количество жалоб, и пользователь был заблокирован." + channel_cannot_be_archived: "Канал в данный момент не может быть заархивирован, он должен быть либо закрыт, либо открыт для архивации." + duplicate_message: "Вы отправляете одно и то же сообщение слишком часто." + delete_channel_failed: "Не удалось удалить канал, попробуйте ещё раз." + minimum_length_not_met: "Сообщение слишком короткое, оно должно содержать не менее %{minimum} символов." + max_reactions_limit_reached: "Новые реакции на это сообщение запрещены." + message_move_invalid_channel: "Исходный и целевой каналы должны быть общедоступными." + message_move_no_messages_found: "Не найдено сообщений с указанными идентификаторами сообщений." + cant_update_direct_message_channel: "Такие свойства канала как название и описание, не могут быть обновлены." + not_accepting_dms: "Извините, пользователь %{username} в данный момент не принимает личные \nсообщения." + actor_ignoring_target_user: "Вы игнорируете %{username}, поэтому не можете отправлять им личные сообщения." + actor_muting_target_user: "Вы отключили все уведомления от %{username}, поэтому вы не можете отправлять им личные сообщения." + actor_disallowed_dms: "Вы решили запретить пользователям отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать новые прямые сообщения." + actor_preventing_target_user_from_dm: "Вы решили запретить %{username} отправлять вам личные и прямые сообщения чата, поэтому вы не можете создавать для них новые прямые сообщения." + user_cannot_send_direct_messages: "К сожалению, вы не можете отправлять прямые сообщения." + reviewables: + message_already_handled: "Спасибо, но мы уже рассмотрели жалобу на это сообщение, поэтому жаловаться на него снова нет необходимости." + actions: + agree: + title: "Согласиться..." + agree_and_keep_message: + title: "Оставить сообщение" + description: "Согласиться с жалобой и оставить сообщение без изменений." + agree_and_keep_deleted: + title: "Оставить сообщение уделённым" + description: "Согласиться с жалобой и оставить сообщение удалённым." + agree_and_suspend: + title: "Заморозить пользователя" + description: "Согласиться с жалобой и заморозить пользователя." + agree_and_silence: + title: "Pf,kjrbhjdfnm gjkmpjdfntkz" + description: "Согласиться с жалобой и блокировать пользователя." + agree_and_restore: + title: "Восстановить сообщение" + description: "Восстановить сообщение, чтобы пользователи могли его видеть." + agree_and_delete: + title: "Удалить сообщение" + description: "Удалить сообщение, чтобы пользователи не могли его видеть." + delete_and_agree: + title: "Удалить сообщение" + disagree_and_restore: + title: "Отклонить жалобу и восстановить сообщение" + description: "Восстановить сообщение, чтобы все пользователи могли его видеть." + disagree: + title: "Отклонить" + ignore: + title: "Игнорировать" + direct_messages: + transcript_title: "Содержимое предыдущих сообщений в канале %{channel_name}" + transcript_body: "Чтобы дать больше контекста, мы отображаем содержимое предыдущих сообщений этой беседы (до десяти):\n\n%{transcript}" + channel: + statuses: + read_only: "Только для чтения" + archived: "В архиве" + closed: "Закрыт" + open: "Открыт" + archive: + first_post_raw: "Эта тема является архивом канала [%{channel_name}](%{channel_url})." + messages_moved: + one: "Пользователь @%{acting_username} переместил сообщение в канал [%{channel_name}](%{first_moved_message_url})." + few: "Пользователь @%{acting_username} переместил %{count} сообщения в канал [%{channel_name}](%{first_moved_message_url})." + many: "Пользователь @%{acting_username} переместил %{count} сообщений в канал [%{channel_name}](%{first_moved_message_url})." + other: "Пользователь @%{acting_username} переместил %{count} сообщений в канал [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} и ещё %{leftover}" + bookmarkable: + notification_title: "Сообщение в канале %{channel_name}" + personal_chat: "личный чат" + onebox: + inline_to_message: "Сообщение №%{message_id} от пользователя %{username} — №%{chat_channel}" + inline_to_channel: "Чат №%{chat_channel}" + inline_to_topic_channel: "Чат по теме %{topic_title}" + x_members: + one: "%{count} участник" + few: "%{count} участника" + many: "%{count} участников" + other: "%{count} участников" + and_x_others: + one: "и ещё %{count}" + few: "и ещё %{count}" + many: "и ещё %{count}" + other: "и ещё %{count}" + discourse_push_notifications: + popup: + chat_mention: + direct: 'Пользователь %{username} упомянул вас на канале "%{channel}"' + other: 'Пользователь %{username} упомянул %{identifier} на канале "%{channel}"' + direct_message_chat_mention: + direct: "Пользователь %{username} упомянул вас в личном чате" + other: "Пользователь %{username} упомянул %{identifier} в личном чате" + new_chat_message: 'Пользователь %{username} отправил сообщение на канале "%{channel}"' + new_direct_chat_message: "Пользователь %{username} отправил сообщение в личный чат" + discourse_automation: + scriptables: + send_chat_message: + title: Отправить сообщение + reviewable_score_types: + needs_review: + title: "Требуется проверка" + notify_user: + chat_pm_title: 'Ваше сообщение в канале ''%{channel_name}''' + chat_pm_body: "%{link}\n\n%{message}" + notify_moderators: + chat_pm_title: 'Сообщение в канале ''%{channel_name}'' требует внимания модератора' + chat_pm_body: "%{link}\n\n%{message}" + reviewables: + reasons: + chat_message_queued_by_staff: "Сотрудник считает, что это сообщение должно быть отправлено на премодерацию." + user_notifications: + chat_summary: + deleted_user: "Удалённый пользователь" + description: + one: "У вас в чате одно новое сообщение" + few: "У вас в чате есть новые сообщения" + many: "У вас в чате есть новые сообщения" + other: "У вас в чате есть новые сообщения" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Новое сообщение от %{message_title}" + few: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + many: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + other: "[%{email_prefix}] Новые сообщения от %{message_title} и %{others}" + chat_channel: + one: "[%{email_prefix}] Новое сообщение в %{message_title}" + few: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + many: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + other: "[%{email_prefix}] Новые сообщения в %{message_title} и %{others}" + other_direct_message: "от %{message_title}" + others: "%{count} других" + unsubscribe: "Этот дайджест чата отправляется с сайта %{site_link} в период вашего отсутствия на форуме. Для отмены подписки измените %{email_preferences_link} или %{unsubscribe_link}." + unsubscribe_no_link: "Этот дайджест чата рассылается с сайта %{site_link} в период вашего отсутствия на форуме. Настройка рассылки: %{email_preferences_link}." + view_messages: + one: "Посмотреть %{count} сообщение" + few: "Посмотреть %{count} сообщения" + many: "Посмотреть %{count} сообщений" + other: "Посмотреть %{count} сообщений" + view_more: + one: "Посмотреть ещё %{count} сообщение" + few: "Посмотреть ещё %{count} сообщения" + many: "Посмотреть ещё %{count} сообщений" + other: "Посмотреть ещё %{count} сообщений" + your_chat_settings: "Настройка частоты рассылки дайджеста чата" + unsubscribe: + chat_summary: + select_title: "Настройте частоту получения электронных писем с дайджестами чата:" + never: Никогда + when_away: Если вы находитесь офлайн + category: + cannot_delete: + has_chat_channels: "Невозможно удалить этот раздел, поскольку в нём есть каналы чата." diff --git a/plugins/chat/config/locales/server.sk.yml b/plugins/chat/config/locales/server.sk.yml new file mode 100644 index 00000000000..6f815624081 --- /dev/null +++ b/plugins/chat/config/locales/server.sk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: diff --git a/plugins/chat/config/locales/server.sl.yml b/plugins/chat/config/locales/server.sl.yml new file mode 100644 index 00000000000..23489a48b1f --- /dev/null +++ b/plugins/chat/config/locales/server.sl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: diff --git a/plugins/chat/config/locales/server.sq.yml b/plugins/chat/config/locales/server.sq.yml new file mode 100644 index 00000000000..7f051b7a7cf --- /dev/null +++ b/plugins/chat/config/locales/server.sq.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: diff --git a/plugins/chat/config/locales/server.sr.yml b/plugins/chat/config/locales/server.sr.yml new file mode 100644 index 00000000000..88d63d6ae1a --- /dev/null +++ b/plugins/chat/config/locales/server.sr.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: diff --git a/plugins/chat/config/locales/server.sv.yml b/plugins/chat/config/locales/server.sv.yml new file mode 100644 index 00000000000..ee27666e358 --- /dev/null +++ b/plugins/chat/config/locales/server.sv.yml @@ -0,0 +1,162 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + site_settings: + chat_enabled: "Aktivera tillägget discourse-chatt." + chat_channel_retention_days: "Chattmeddelanden i ordinarie kanaler kommer att behållas i så här många dagar. Sätt till '0' för att behålla meddelanden för alltid." + chat_dm_retention_days: "Chattmeddelanden i personliga chattkanaler kommer att behållas i så här många dagar. Sätt till '0' för att behålla meddelanden för alltid." + chat_auto_silence_duration: "Antal minuter som användare kommer att tystas när de överskrider antalsgränsen för skapande av chattmeddelanden. Ställ in på '0' för att inaktivera automatisk tystning." + chat_allowed_messages_for_trust_level_0: "Antal meddelanden som användare på förtroendenivå 0 får skicka inom 30 sekunder. Ange '0' för att inaktivera gränsen." + chat_allowed_messages_for_other_trust_levels: "Antal meddelanden som användare med förtroendenivå 1-4 får skicka inom 30 sekunder. Ange '0' för att inaktivera gränsen." + chat_silence_user_sensitivity: "Sannolikheten för att en användare som flaggas i chatten automatiskt tystas." + chat_auto_silence_from_flags_duration: "Antal minuter som användarna tystas i när de automatiskt tystas på grund av markerade chattmeddelanden." + chat_default_channel_id: "Chattkanalen som öppnas som standard när en användare inte har olästa meddelanden eller omnämnanden i andra kanaler." + chat_duplicate_message_sensitivity: "Sannolikheten att ett duplicerat meddelande från samma avsändare blockeras inom en kort tidsperiod. Decimaltal mellan 0 och 1,0, där 1,0 är den högsta inställningen (blockerar meddelanden oftare på kortare tid). Ställ in `0` för att tillåta dubbletter av meddelanden." + chat_minimum_message_length: "Minsta antal tecken för ett chattmeddelande." + chat_archive_destination_topic_status: "Den status som målämnet ska ha när ett kanalarkiv är slutfört. Detta gäller endast när målämnet är ett nytt ämne, inte ett befintligt." + default_emoji_reactions: "Standardval av emoji-reaktioner för chattmeddelanden. Lägg till upp till 5 emojis som snabbval." + errors: + chat_default_channel: "Standardchattkanalen måste vara en offentlig kanal." + system_messages: + chat_channel_archive_complete: + title: "Arkivering av chattkanalen är färdigt" + subject_template: "Arkivering av chattkanalen slutfördes framgångsrikt" + text_body_template: | + Arkivering av chattkanalen **\#%{channel_name}** har slutförts. Meddelandena har kopierats till ämnet [%{topic_title}](%{topic_url}). + chat_channel_archive_failed: + title: "Arkivering av chattkanalen misslyckades" + subject_template: "Arkivering av chattkanalen misslyckades" + text_body_template: | + Arkivering av chatt kanalen **\#%{channel_name}** misslyckades. %{messages_archived} meddelanden har arkiverats. Delvis arkiverade meddelanden kopierades till ämnet [%{topic_title}](%{topic_url}). Besök kanalen på %{channel_url} för att försöka igen. + chat: + deleted_chat_username: raderad + errors: + channel_exists_for_category: "En kanal finns redan för denna kategori och namn" + channel_new_message_disallowed: "Kanalen är %{status}, inga nya meddelanden kan skickas" + channel_modify_message_disallowed: "Kanalen är %{status}, inga meddelanden kan redigeras eller tas bort" + user_cannot_send_message: "Du kan inte skicka meddelanden just nu." + rate_limit_exceeded: "Överskred gränsen för chattmeddelanden som kan skickas inom 30 sekunder" + auto_silence_from_flags: "Chattmeddelande flaggat med tillräckligt hög poäng för att tysta användaren." + channel_cannot_be_archived: "Kanalen kan inte arkiveras just nu, den måste vara antingen stängd eller öppen för arkivering." + duplicate_message: "Du skrev också ett identiskt meddelande nyligen." + delete_channel_failed: "Det gick inte att ta bort kanalen, försök igen." + minimum_length_not_met: "Meddelandet är för kort, måste ha minst %{minimum} tecken." + max_reactions_limit_reached: "Nya reaktioner är inte tillåtna för detta meddelande." + message_move_invalid_channel: "Käll- och destinationskanalen måste vara offentliga kanaler." + message_move_no_messages_found: "Inga meddelanden hittades med de angivna meddelande-ID:n." + cant_update_direct_message_channel: "Egenskaper för direktmeddelandekanal såsom namn och beskrivning kan inte uppdateras." + not_accepting_dms: "Tyvärr, %{username} accepterar inte meddelanden för tillfället." + actor_ignoring_target_user: "Du ignorerar %{username}, så du kan inte skicka meddelanden till dem." + actor_muting_target_user: "Du har tystat %{username}, så du kan inte skicka meddelanden till dem." + actor_disallowed_dms: "Du har valt att hindra användare från att skicka dig privata och direkta meddelanden, så du kan inte skapa nya direkta meddelanden." + actor_preventing_target_user_from_dm: "Du har valt att hindra %{username} från att skicka privata och direkta meddelanden, så du kan inte skapa nya direktmeddelanden till dem." + reviewables: + actions: + agree: + title: "Håller med..." + agree_and_keep_message: + title: "Behåll meddelande" + description: "Håll med flaggning men behåll meddelandet oförändrat." + agree_and_keep_deleted: + title: "Behåll meddelandet raderat" + description: "Håll med flaggning och behåll meddelandet raderat." + agree_and_suspend: + title: "Stäng av användare" + description: "Håll med flaggning och stäng av användaren." + agree_and_silence: + title: "Tysta användare" + description: "Håll med flaggning och tysta användaren." + agree_and_restore: + title: "Återställ meddelande" + description: "Återställ meddelandet så att användarna kan se det." + agree_and_delete: + title: "Radera meddelande" + description: "Ta bort meddelandet så att användarna inte kan se det." + delete_and_agree: + title: "Radera meddelande" + disagree_and_restore: + title: "Håll inte med och återställ meddelande" + description: "Återställ meddelandet så att alla användare kan se det." + disagree: + title: "Oöverens" + ignore: + title: "Ignorera" + channel: + statuses: + read_only: "Endast läsning" + archived: "Arkiverad" + closed: "Stängd" + open: "Öppen" + archive: + first_post_raw: "Detta ämne är ett arkiv av chatt kanalen [%{channel_name}](%{channel_url})." + messages_moved: + one: "@%{acting_username} flyttade ett meddelande till kanalen [%{channel_name}](%{first_moved_message_url})." + other: "@%{acting_username} flyttade %{count} meddelanden till kanalen [%{channel_name}](%{first_moved_message_url})." + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} och %{leftover} andra" + bookmarkable: + notification_title: "meddelande i %{channel_name}" + personal_chat: "personlig chatt" + onebox: + inline_to_message: "Meddelande #%{message_id} av %{username} – #%{chat_channel}" + inline_to_channel: "Chatt #%{chat_channel}" + inline_to_topic_channel: "Chatt för ämne %{topic_title}" + x_members: + one: "%{count} medlem" + other: "%{count} medlemmar" + and_x_others: + one: "och %{count} annan" + other: "och %{count} andra" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} nämnde dig i "%{channel}"' + other: '%{username} nämnde %{identifier} i "%{channel}"' + direct_message_chat_mention: + direct: "%{username} nämnde dig i en personlig chatt" + other: "%{username} nämnde %{identifier} i personlig chatt" + new_chat_message: '%{username} skickade ett meddelande i "%{channel}"' + new_direct_chat_message: "%{username} skickade ett meddelande i personlig chatt" + discourse_automation: + scriptables: + send_chat_message: + title: Skicka chattmeddelande + reviewable_score_types: + needs_review: + title: "Behöver granskning" + user_notifications: + chat_summary: + deleted_user: "Raderad användare" + description: + one: "Du har ett nytt chattmeddelande" + other: "Du har nya chattmeddelanden" + from: "%{site_name}" + subject: + direct_message: + one: "[%{email_prefix}] Nytt meddelande från %{message_title}" + other: "[%{email_prefix}] Nya meddelanden från %{message_title} och %{others}" + chat_channel: + one: "[%{email_prefix}] Nytt meddelande i %{message_title}" + other: "[%{email_prefix}] Nya meddelanden i %{message_title} och %{others}" + other_direct_message: "från %{message_title}" + others: "%{count} andra" + unsubscribe: "Den här chattsammanfattningen skickas från %{site_link} när du är borta. Ändra din %{email_preferences_link} eller %{unsubscribe_link} för att avsluta prenumerationen." + unsubscribe_no_link: "Den här chattsammanfattningen skickas från %{site_link} när du är borta. Ändra din %{email_preferences_link}." + view_messages: + one: "Visa meddelande" + other: "Visa %{count} meddelanden" + view_more: + one: "Visa %{count} mer meddelande" + other: "Visa %{count} fler meddelanden" + your_chat_settings: "preferens för frekvens av chattmeddelanden" + unsubscribe: + chat_summary: + select_title: "Ställ in e-postfrekvensen för chattsammanfattningar till:" + never: Aldrig + when_away: Endast när du är borta diff --git a/plugins/chat/config/locales/server.sw.yml b/plugins/chat/config/locales/server.sw.yml new file mode 100644 index 00000000000..0d7cdd075bf --- /dev/null +++ b/plugins/chat/config/locales/server.sw.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: diff --git a/plugins/chat/config/locales/server.te.yml b/plugins/chat/config/locales/server.te.yml new file mode 100644 index 00000000000..03967bdbb07 --- /dev/null +++ b/plugins/chat/config/locales/server.te.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: diff --git a/plugins/chat/config/locales/server.th.yml b/plugins/chat/config/locales/server.th.yml new file mode 100644 index 00000000000..7de85ff91c4 --- /dev/null +++ b/plugins/chat/config/locales/server.th.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: diff --git a/plugins/chat/config/locales/server.tr_TR.yml b/plugins/chat/config/locales/server.tr_TR.yml new file mode 100644 index 00000000000..3e1142a83a0 --- /dev/null +++ b/plugins/chat/config/locales/server.tr_TR.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: diff --git a/plugins/chat/config/locales/server.uk.yml b/plugins/chat/config/locales/server.uk.yml new file mode 100644 index 00000000000..f1390545d1d --- /dev/null +++ b/plugins/chat/config/locales/server.uk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: diff --git a/plugins/chat/config/locales/server.ur.yml b/plugins/chat/config/locales/server.ur.yml new file mode 100644 index 00000000000..b4a9c21ee2f --- /dev/null +++ b/plugins/chat/config/locales/server.ur.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: diff --git a/plugins/chat/config/locales/server.vi.yml b/plugins/chat/config/locales/server.vi.yml new file mode 100644 index 00000000000..f629dcf5329 --- /dev/null +++ b/plugins/chat/config/locales/server.vi.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: diff --git a/plugins/chat/config/locales/server.zh_CN.yml b/plugins/chat/config/locales/server.zh_CN.yml new file mode 100644 index 00000000000..abaae4ecdee --- /dev/null +++ b/plugins/chat/config/locales/server.zh_CN.yml @@ -0,0 +1,157 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + site_settings: + chat_enabled: "启用discourse-chat插件。" + chat_channel_retention_days: "常规频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" + chat_dm_retention_days: "个人聊天频道中的聊天消息将保留此天数。设置为 0 将永久保留消息。" + chat_auto_silence_duration: "用户超过聊天消息创建速率限制时将被静音的分钟数。设置为“0”以禁用自动静音。" + chat_allowed_messages_for_trust_level_0: "信任级别 0 用户在 30 秒内允许发送的消息数。设置为“0”以禁用限制。" + chat_allowed_messages_for_other_trust_levels: "信任级别 1-4 的用户在 30 秒内允许发送的消息数。设置为“0”以禁用限制。" + chat_silence_user_sensitivity: "聊天中被标记的用户被自动静音的可能性。" + chat_auto_silence_from_flags_duration: "用户因聊天消息被标记而自动静音时将被静音的分钟数。" + chat_default_channel_id: "当用户在其他频道没有未读消息或提及时,将默认打开的聊天频道。" + chat_duplicate_message_sensitivity: "同一发件人的重复信息在短时间内被阻止的可能性。十进制数字,介于0和1.0之间,1.0为最高设置(在较短的时间内更频繁地阻止信息)。设置为 \"0 \"表示允许重复的信息。" + chat_minimum_message_length: "聊天消息允许的最少字符数。" + chat_archive_destination_topic_status: "频道归档完成后目标主题应处于的状态。这仅适用于目标主题是新主题而不是现有主题的情况。" + default_emoji_reactions: "聊天信息的默认表情符号反应。最多可添加5个表情符号进行快速反应。" + errors: + chat_default_channel: "默认聊天频道必须是公共频道。" + system_messages: + chat_channel_archive_complete: + title: "聊天频道归档完成" + subject_template: "聊天频道归档成功完成" + text_body_template: | + 存档聊天频道**\#%{channel_name}** 已成功完成。消息已被复制到主题 [%{topic_title}](%{topic_url}) 中。 + chat_channel_archive_failed: + title: "聊天频道归档失败" + subject_template: "聊天频道归档失败" + text_body_template: | + 聊天频道**#%{channel_name}**归档失败。 %{messages_archived} 信息已被归档。部分归档的消息被复制到主题[%{topic_title}](%{topic_url})。请访问 %{channel_url} 频道以重新尝试。 + chat: + deleted_chat_username: 已删除 + errors: + channel_exists_for_category: "此类别和名称的频道已经存在" + channel_new_message_disallowed: "频道 %{status},不能发送新消息" + channel_modify_message_disallowed: "频道 %{status},无法编辑或删除消息" + user_cannot_send_message: "您目前无法发送消息。" + rate_limit_exceeded: "超过了30秒内可发送的聊天信息的上限" + auto_silence_from_flags: "聊天消息标记得分高到足以将用户静音。" + channel_cannot_be_archived: "该频道此时不能被归档,它必须被关闭或启用以归档。" + duplicate_message: "您在短时间内发布了一条相同的消息。" + delete_channel_failed: "删除频道失败,请重试。" + minimum_length_not_met: "消息太短,必须至少有 %{minimum} 个字符。" + max_reactions_limit_reached: "在这个信息上不允许有新的反应。" + message_move_invalid_channel: "源频道和目标频道必须是公共频道。" + message_move_no_messages_found: "没有找到带有提供消息ID的消息。" + cant_update_direct_message_channel: "DM频道的名称和描述等属性无法被更新。" + not_accepting_dms: "抱歉, %{username} 目前不接受聊天消息。" + actor_ignoring_target_user: "你忽略了 %{username},所以你不能向他们发送消息。" + actor_muting_target_user: "您正在静音 %{username},因此您无法向他们发送消息。" + actor_disallowed_dms: "您已选择阻止用户向您发送私人和聊天消息,因此您无法创建新的私聊。" + actor_preventing_target_user_from_dm: "您已选择阻止 %{username} 向您发送私人和聊天消息,因此您无法向他们创建新的私聊。" + reviewables: + actions: + agree: + title: "同意…" + agree_and_keep_message: + title: "保留消息" + description: "同意举报并保持消息不变。" + agree_and_keep_deleted: + title: "保持消息删除状态" + description: "同意举报并保持消息删除状态。" + agree_and_suspend: + title: "封禁用户" + description: "同意举报并封禁用户。" + agree_and_silence: + title: "将用户禁言" + description: "同意举报并将用户禁言。" + agree_and_restore: + title: "恢复消息" + description: "恢复消息,以便用户可以看到。" + agree_and_delete: + title: "删除消息" + description: "删除消息,使用户看不到。" + delete_and_agree: + title: "删除消息" + disagree_and_restore: + title: "不同意并恢复消息" + description: "恢复消息,以便所有用户都可以看到。" + disagree: + title: "不同意" + ignore: + title: "忽略" + channel: + statuses: + read_only: "只读" + archived: "已归档" + closed: "已关闭" + open: "启用" + archive: + first_post_raw: "本主题是 [%{channel_name}](%{channel_url}) 聊天频道的存档。" + messages_moved: + other: "@%{acting_username} 将 %{count} 条消息移至 [%{channel_name}](%{first_moved_message_url}) 频道。" + dm_title: + single_user: "%{user}" + multi_user: "%{users}" + multi_user_truncated: "%{users} 和 其他%{leftover} 人" + bookmarkable: + notification_title: "%{channel_name}频道中的信息" + personal_chat: "个人聊天" + onebox: + inline_to_message: "消息 #%{message_id} by %{username} – #%{chat_channel}" + inline_to_channel: "聊天#%{chat_channel}" + inline_to_topic_channel: "话题 %{topic_title}的聊天" + x_members: + other: "%{count} 个成员" + and_x_others: + other: "其他 %{count} 人" + discourse_push_notifications: + popup: + chat_mention: + direct: '%{username} 在“%{channel}”中提到了你' + other: '%{username} 在 “%{channel}”中提到 %{identifier}' + direct_message_chat_mention: + direct: "%{username} 在私聊中提到了你" + other: "%{username} 私聊中提到 %{identifier}" + new_chat_message: '%{username} 在“%{channel}”中发送了一条消息' + new_direct_chat_message: "%{username} 在私聊中发送了一条消息" + discourse_automation: + scriptables: + send_chat_message: + title: 发送聊天消息 + reviewable_score_types: + needs_review: + title: "需要审核" + user_notifications: + chat_summary: + deleted_user: "已被删除的用户" + description: + other: "您有新的聊天消息" + from: "%{site_name}" + subject: + direct_message: + other: "[%{email_prefix}] 来自 %{message_title} 和 %{others}的新消息" + chat_channel: + other: "[%{email_prefix}] 来自 %{message_title} 和 %{others}的新消息" + other_direct_message: "来自 %{message_title}" + others: "其他 %{count} 人" + unsubscribe: "当您不在时,此聊天摘要会从 %{site_link} 发送。更改您的 %{email_preferences_link}或 %{unsubscribe_link} 以取消订阅。" + unsubscribe_no_link: "此聊天摘要在您离开时从 %{site_link} 发送。更改你的 %{email_preferences_link}." + view_messages: + other: "查看 %{count} 条消息" + view_more: + other: "查看 %{count} 条消息" + your_chat_settings: "聊天电子邮件频率偏好" + unsubscribe: + chat_summary: + select_title: "将聊天摘要电子邮件频率设置为:" + never: 永不 + when_away: 仅在离开时 + category: + cannot_delete: + has_chat_channels: "无法删除此类别,因为它有关联的聊天频道。" diff --git a/plugins/chat/config/locales/server.zh_TW.yml b/plugins/chat/config/locales/server.zh_TW.yml new file mode 100644 index 00000000000..7e15fab0018 --- /dev/null +++ b/plugins/chat/config/locales/server.zh_TW.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml new file mode 100644 index 00000000000..26c884b2d35 --- /dev/null +++ b/plugins/chat/config/settings.yml @@ -0,0 +1,95 @@ +plugins: + chat_enabled: + default: false + client: true + chat_allowed_groups: + client: true + type: group_list + list_type: compact + default: "3" # 3 is staff group id + allow_any: false + refresh: true + needs_chat_seeded: + default: true + hidden: true + chat_debug_webhook_payloads: + default: false + hidden: true + chat_channel_retention_days: + default: 90 + client: true + max: 3652 # 10 years + min: 0 + chat_dm_retention_days: + default: 0 + client: true + max: 3652 # 10 years + min: 0 + chat_auto_silence_duration: + default: 30 + min: 0 + chat_allowed_messages_for_trust_level_0: + default: 20 + min: 0 + chat_allowed_messages_for_other_trust_levels: + default: 40 + min: 0 + chat_silence_user_sensitivity: + type: enum + enum: "ReviewableSensitivitySetting" + default: 6 + chat_auto_silence_from_flags_duration: + default: 60 + min: 0 + chat_allow_archiving_channels: + default: false + hidden: true + client: true + chat_archive_destination_topic_status: + type: enum + default: archived + choices: + - archived + - open + - closed + chat_default_channel_id: + default: "" + client: true + validator: "ChatDefaultChannelValidator" + chat_duplicate_message_sensitivity: + type: float + default: 0.5 + min: 0 + max: 1 + default_emoji_reactions: + type: emoji_list + default: +1|heart|tada + client: true + chat_minimum_message_length: + type: integer + default: 1 + min: 1 + max: 50 + client: true + chat_allow_uploads: + default: true + client: true + validator: "ChatAllowUploadsValidator" + max_chat_auto_joined_users: + min: 0 + default: 10000 + hidden: true + client: true + direct_message_enabled_groups: + default: "11" # auto group trust_level_1 + type: group_list + client: true + allow_any: false + refresh: true + validator: "DirectMessageEnabledGroupsValidator" + chat_message_flag_allowed_groups: + default: "11" # auto group trust_level_1 + type: group_list + client: true + allow_any: false + refresh: true diff --git a/plugins/chat/db/fixtures/600_chat_channels.rb b/plugins/chat/db/fixtures/600_chat_channels.rb new file mode 100644 index 00000000000..972398ba7f0 --- /dev/null +++ b/plugins/chat/db/fixtures/600_chat_channels.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ChatSeeder.new.execute if !Rails.env.test? diff --git a/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb b/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb new file mode 100644 index 00000000000..21844f7ea64 --- /dev/null +++ b/plugins/chat/db/migrate/20210225230057_create_chat_tables.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateChatTables < ActiveRecord::Migration[6.0] + def change + create_table :topic_chats do |t| + t.integer :topic_id, null: false, index: true, unique: true + t.datetime :deleted_at + t.integer :deleted_by_id + + t.integer :featured_in_category_id + t.integer :delete_after_seconds, default: nil + end + + create_table :topic_chat_messages do |t| + t.integer :topic_id, null: false + t.integer :post_id, null: false, index: true + t.integer :user_id, null: true + t.timestamps + t.datetime :deleted_at + t.integer :deleted_by_id + t.integer :in_reply_to_id, null: true + t.text :message + end + + add_index :topic_chat_messages, %i[topic_id created_at] + end +end diff --git a/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb b/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb new file mode 100644 index 00000000000..522ad28f16d --- /dev/null +++ b/plugins/chat/db/migrate/20210403025854_add_action_code_to_topic_chat_message.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddActionCodeToTopicChatMessage < ActiveRecord::Migration[6.0] + def change + add_column :topic_chat_messages, :action_code, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb b/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb new file mode 100644 index 00000000000..134417d385f --- /dev/null +++ b/plugins/chat/db/migrate/20210706214013_rename_topic_chats_to_chat_channels.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class RenameTopicChatsToChatChannels < ActiveRecord::Migration[6.1] + def up + begin + Migration::SafeMigrate.disable! + + # Trash all existing chat info + DB.exec("DELETE FROM topic_chats") + DB.exec("DELETE FROM topic_chat_messages") + + # topic_chat table changes + rename_table :topic_chats, :chat_channels + rename_column :chat_channels, :topic_id, :chatable_id + change_column :chat_channels, :chatable_id, :integer, unique: false + add_column :chat_channels, :chatable_type, :string + change_column_null :chat_channels, :chatable_type, false + add_index :chat_channels, %i[chatable_id chatable_type] + + # topic_chat_messages table changes + rename_table :topic_chat_messages, :chat_messages + rename_column :chat_messages, :topic_id, :chat_channel_id + change_column_null :chat_messages, :post_id, true # Don't require post_id + ensure + Migration::SafeMigrate.enable! + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb b/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb new file mode 100644 index 00000000000..423ccffd41e --- /dev/null +++ b/plugins/chat/db/migrate/20210729134042_create_chat_message_revisions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateChatMessageRevisions < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_revisions do |t| + t.integer :chat_message_id + t.text :old_message, null: false + t.text :new_message, null: false + t.timestamps + end + + add_index :chat_message_revisions, [:chat_message_id] + end +end diff --git a/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb b/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb new file mode 100644 index 00000000000..a0b7304687d --- /dev/null +++ b/plugins/chat/db/migrate/20210730134847_create_user_chat_channel_last_read.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateUserChatChannelLastRead < ActiveRecord::Migration[6.1] + def change + create_table :user_chat_channel_last_reads do |t| + t.integer :chat_channel_id, null: false + t.integer :chat_message_id, null: true # Can be null if user hasn't opened the channel + t.integer :user_id, null: false + end + + add_index :user_chat_channel_last_reads, + %i[chat_channel_id user_id], + unique: true, + name: "user_chat_channel_reads_index" + end +end diff --git a/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb b/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb new file mode 100644 index 00000000000..3b231e0782f --- /dev/null +++ b/plugins/chat/db/migrate/20210812145801_create_direct_message_tables.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateDirectMessageTables < ActiveRecord::Migration[6.1] + def change + create_table :direct_message_channels do |t| + t.timestamps + end + + create_table :direct_message_users do |t| + t.integer :direct_message_channel_id, null: false + t.integer :user_id, null: false + t.timestamps + end + + add_index :direct_message_users, + %i[direct_message_channel_id user_id], + unique: true, + name: "direct_message_users_index" + end +end diff --git a/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb b/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb new file mode 100644 index 00000000000..773a778c9f5 --- /dev/null +++ b/plugins/chat/db/migrate/20210813141741_add_timestamps_to_chat_channels.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class AddTimestampsToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :created_at, :timestamp + add_column :chat_channels, :updated_at, :timestamp + + DB.exec("UPDATE chat_channels SET created_at = NOW() WHERE created_at IS NULL") + DB.exec("UPDATE chat_channels SET updated_at = NOW() WHERE updated_at IS NULL") + + change_column_null :chat_channels, :created_at, false + change_column_null :chat_channels, :updated_at, false + end +end diff --git a/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb b/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb new file mode 100644 index 00000000000..23cc115b74d --- /dev/null +++ b/plugins/chat/db/migrate/20210819202912_create_incoming_chat_webhooks.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class CreateIncomingChatWebhooks < ActiveRecord::Migration[6.1] + def change + create_table :incoming_chat_webhooks do |t| + t.string :name, null: false + t.string :key, null: false + t.integer :chat_channel_id, null: false + t.string :username + t.string :description + t.string :emoji + + t.timestamps + end + + add_index :incoming_chat_webhooks, %i[key chat_channel_id] + end +end diff --git a/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb b/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb new file mode 100644 index 00000000000..4e957677549 --- /dev/null +++ b/plugins/chat/db/migrate/20210823160357_create_chat_webhook_events.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateChatWebhookEvents < ActiveRecord::Migration[6.1] + def change + create_table :chat_webhook_events do |t| + t.integer :chat_message_id, null: false + t.integer :incoming_chat_webhook_id, null: false + t.timestamps + end + + add_index :chat_webhook_events, + %i[chat_message_id incoming_chat_webhook_id], + unique: true, + name: "chat_webhook_events_index" + end +end diff --git a/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb b/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb new file mode 100644 index 00000000000..81eea0a67c2 --- /dev/null +++ b/plugins/chat/db/migrate/20210901130308_create_user_chat_channel_membership.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +class CreateUserChatChannelMembership < ActiveRecord::Migration[6.1] + def change + create_table :user_chat_channel_memberships do |t| + t.integer :user_id, null: false + t.integer :chat_channel_id, null: false + t.integer :last_read_message_id + t.boolean :following, default: false, null: false # membership on/off switch + t.boolean :muted, default: false, null: false + t.integer :desktop_notification_level, default: 1, null: false + t.integer :mobile_notification_level, default: 1, null: false + t.timestamps + end + + add_index :user_chat_channel_memberships, + %i[ + user_id + chat_channel_id + desktop_notification_level + mobile_notification_level + following + ], + name: "user_chat_channel_memberships_index" + + add_index :user_chat_channel_memberships, + %i[user_id chat_channel_id], + unique: true, + name: "user_chat_channel_unique_memberships" + end +end diff --git a/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb b/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb new file mode 100644 index 00000000000..d30e6903621 --- /dev/null +++ b/plugins/chat/db/migrate/20210930144333_add_chat_enabled_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddChatEnabledToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_enabled, :boolean, default: true, null: false + end +end diff --git a/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb b/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb new file mode 100644 index 00000000000..dab116d3c01 --- /dev/null +++ b/plugins/chat/db/migrate/20211022151713_create_chat_message_post_connections.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateChatMessagePostConnections < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_post_connections do |t| + t.integer :post_id, null: false + t.integer :chat_message_id, null: false + t.timestamps + end + + add_index :chat_message_post_connections, + %i[post_id chat_message_id], + unique: true, + name: "chat_message_post_connections_index" + end +end diff --git a/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb b/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb new file mode 100644 index 00000000000..8844686c9aa --- /dev/null +++ b/plugins/chat/db/migrate/20211029145508_add_chat_isolated_to_user_options.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddChatIsolatedToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_isolated, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb b/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb new file mode 100644 index 00000000000..5de025e4ed6 --- /dev/null +++ b/plugins/chat/db/migrate/20211104141254_add_only_chat_push_notifications_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddOnlyChatPushNotificationsToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :only_chat_push_notifications, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb b/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb new file mode 100644 index 00000000000..876de7cf2d8 --- /dev/null +++ b/plugins/chat/db/migrate/20211119142000_add_cooked_to_chat_messages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddCookedToChatMessages < ActiveRecord::Migration[6.1] + def change + add_column :chat_messages, :cooked, :text + add_column :chat_messages, :cooked_version, :integer + end +end diff --git a/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb b/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb new file mode 100644 index 00000000000..7eb9d566835 --- /dev/null +++ b/plugins/chat/db/migrate/20211129171229_create_chat_uploads.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateChatUploads < ActiveRecord::Migration[6.1] + def change + create_table :chat_uploads do |t| + t.integer :chat_message_id, null: false + t.integer :upload_id, null: false + t.timestamps + end + + add_index :chat_uploads, %i[chat_message_id upload_id], unique: true + end +end diff --git a/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb b/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb new file mode 100644 index 00000000000..377849e5096 --- /dev/null +++ b/plugins/chat/db/migrate/20211201171813_create_chat_reactions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class CreateChatReactions < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_reactions do |t| + t.integer :chat_message_id + t.integer :user_id + t.string :emoji + t.timestamps + end + + add_index :chat_message_reactions, + %i[chat_message_id user_id emoji], + unique: true, + name: :chat_message_reactions_index + end +end diff --git a/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb b/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb new file mode 100644 index 00000000000..26f42042d35 --- /dev/null +++ b/plugins/chat/db/migrate/20211210191830_create_chat_mentions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class CreateChatMentions < ActiveRecord::Migration[6.1] + def change + create_table :chat_mentions do |t| + t.integer :chat_message_id, null: false + t.integer :user_id, null: false + t.integer :notification_id, null: false + t.timestamps + end + + add_index :chat_mentions, + %i[chat_message_id user_id notification_id], + unique: true, + name: "chat_mentions_index" + end +end diff --git a/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb b/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb new file mode 100644 index 00000000000..3eae79c91fd --- /dev/null +++ b/plugins/chat/db/migrate/20211213150607_add_chat_sound_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddChatSoundToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :chat_sound, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb b/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb new file mode 100644 index 00000000000..7a651cbca52 --- /dev/null +++ b/plugins/chat/db/migrate/20211217221026_add_name_to_chat_channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddNameToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :name, :string, null: true + end +end diff --git a/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb b/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb new file mode 100644 index 00000000000..6169fd8f8bf --- /dev/null +++ b/plugins/chat/db/migrate/20211222153716_add_description_to_chat_channels.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddDescriptionToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :description, :text, null: true + end +end diff --git a/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb b/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb new file mode 100644 index 00000000000..1b3c40d8cd8 --- /dev/null +++ b/plugins/chat/db/migrate/20220119170535_add_chat_retention_fields_to_user_options.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddChatRetentionFieldsToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :dismissed_channel_retention_reminder, :boolean, null: true + add_column :user_options, :dismissed_dm_retention_reminder, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb b/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb new file mode 100644 index 00000000000..aa33c9d1fef --- /dev/null +++ b/plugins/chat/db/migrate/20220203204002_create_chat_drafts_table.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateChatDraftsTable < ActiveRecord::Migration[6.1] + def change + create_table :chat_drafts do |t| + t.integer :user_id, null: false + t.integer :chat_channel_id, null: false + t.text :data, null: false + t.timestamps + end + end +end diff --git a/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb b/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb new file mode 100644 index 00000000000..8b624115ab1 --- /dev/null +++ b/plugins/chat/db/migrate/20220203204003_migrate_drafts_to_chat_drafts.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class MigrateDraftsToChatDrafts < ActiveRecord::Migration[6.1] + def up + execute <<~SQL + INSERT INTO chat_drafts(user_id, chat_channel_id, data, created_at, updated_at) + SELECT user_id, SUBSTRING(draft_key, LENGTH('chat_') + 1)::integer chat_channel_id, data, created_at, updated_at + FROM drafts + WHERE draft_key LIKE 'chat_%' + SQL + + execute <<~SQL + DELETE FROM drafts + WHERE draft_key LIKE 'chat_%' + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb b/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb new file mode 100644 index 00000000000..2ba06853db6 --- /dev/null +++ b/plugins/chat/db/migrate/20220218023859_add_status_to_chat_channel.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +# +class AddStatusToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :status, :integer, default: 0, null: false + add_index :chat_channels, :status + end +end diff --git a/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb b/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb new file mode 100644 index 00000000000..077f467f0cb --- /dev/null +++ b/plugins/chat/db/migrate/20220228051724_create_chat_channel_archive_table.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# +class CreateChatChannelArchiveTable < ActiveRecord::Migration[6.1] + def change + create_table :chat_channel_archives do |t| + t.integer :chat_channel_id, null: false + t.integer :archived_by_id, null: false + t.integer :destination_topic_id + t.string :destination_topic_title + t.integer :destination_category_id + t.column :destination_tags, :string, array: true + t.integer :total_messages, null: false + t.integer :archived_messages, default: 0, null: false + t.string :archive_error + + t.timestamps + end + + add_index :chat_channel_archives, :chat_channel_id + end +end diff --git a/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb b/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb new file mode 100644 index 00000000000..efcc057cb20 --- /dev/null +++ b/plugins/chat/db/migrate/20220308165620_add_user_count_to_chat_channel.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddUserCountToChatChannel < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :user_count, :integer, null: true, default: 0 + change_column_null :chat_channels, :user_count, false + end +end diff --git a/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb b/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb new file mode 100644 index 00000000000..5f07d4cf8c3 --- /dev/null +++ b/plugins/chat/db/migrate/20220309174820_add_last_message_created_at_to_chat_channels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddLastMessageCreatedAtToChatChannels < ActiveRecord::Migration[6.1] + def change + add_column :chat_channels, :last_message_sent_at, :datetime, default: -> { "CURRENT_TIMESTAMP" } + change_column_null :chat_channels, :last_message_sent_at, false + end +end diff --git a/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb b/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb new file mode 100644 index 00000000000..d902c46281c --- /dev/null +++ b/plugins/chat/db/migrate/20220324062937_ignore_channel_wide_mention_to_user_options.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class IgnoreChannelWideMentionToUserOptions < ActiveRecord::Migration[6.1] + def change + add_column :user_options, :ignore_channel_wide_mention, :boolean, null: true + end +end diff --git a/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb b/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb new file mode 100644 index 00000000000..a1012b44a8b --- /dev/null +++ b/plugins/chat/db/migrate/20220328142120_create_user_chat_message_statuses.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateUserChatMessageStatuses < ActiveRecord::Migration[6.1] + def change + create_table :chat_message_email_statuses do |t| + t.integer :chat_message_id, null: false + t.integer :user_id, null: false + t.integer :status, null: false, default: 0 + t.integer :type, null: false + t.timestamps + end + + add_index :chat_message_email_statuses, + %i[user_id chat_message_id], + name: "chat_message_email_status_user_message_index" + add_index :chat_message_email_statuses, :status + + add_column :user_options, :chat_email_frequency, :integer, default: 1, null: false + add_column :user_options, :last_emailed_for_chat, :datetime, null: true + end +end diff --git a/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb b/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb new file mode 100644 index 00000000000..bf914e8da79 --- /dev/null +++ b/plugins/chat/db/migrate/20220518140004_track_last_unread_mention_when_emailed.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TrackLastUnreadMentionWhenEmailed < ActiveRecord::Migration[7.0] + def change + add_column :user_chat_channel_memberships, :last_unread_mention_when_emailed_id, :integer + end +end diff --git a/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb b/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb new file mode 100644 index 00000000000..b1117a9c05c --- /dev/null +++ b/plugins/chat/db/migrate/20220629190633_auto_join_users_to_channels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AutoJoinUsersToChannels < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :auto_join_users, :boolean, null: false, default: false + end +end diff --git a/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb b/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb new file mode 100644 index 00000000000..2ab6ea1644f --- /dev/null +++ b/plugins/chat/db/migrate/20220706114835_add_join_mode_to_channel_memberships.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddJoinModeToChannelMemberships < ActiveRecord::Migration[7.0] + def change + add_column :user_chat_channel_memberships, :join_mode, :integer, null: false, default: 0 + end +end diff --git a/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb b/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb new file mode 100644 index 00000000000..16a2197be4c --- /dev/null +++ b/plugins/chat/db/migrate/20220729032237_add_index_to_chat_message_created_at.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddIndexToChatMessageCreatedAt < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def change + execute <<~SQL + CREATE INDEX CONCURRENTLY IF NOT EXISTS + idx_chat_messages_by_created_at_not_deleted + ON chat_messages (created_at) + WHERE deleted_at IS NULL + SQL + end + + def down + execute <<~SQL + DROP INDEX IF EXISTS idx_chat_messages_by_created_at_not_deleted + SQL + end +end diff --git a/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb b/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb new file mode 100644 index 00000000000..49a8833cd83 --- /dev/null +++ b/plugins/chat/db/migrate/20220802014549_disable_chat_uploads_if_secure_media_enabled.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class DisableChatUploadsIfSecureMediaEnabled < ActiveRecord::Migration[7.0] + ## + # At this point in time, secure media is not compatible with chat, + # so if it is enabled then chat uploads must be disabled to avoid undesirable + # behaviour. + # + # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep + # it enabled, but this is strongly advised against. + def up + chat_allow_uploads_value = + DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_allow_uploads'").first + + # nil means it is true, since the default value is true + chat_uploads_enabled = chat_allow_uploads_value == "t" || chat_allow_uploads_value.nil? + + secure_media_enabled = + DB.query_single("SELECT value FROM site_settings WHERE name = 'secure_media'").first == "t" + secure_uploads_enabled = + DB.query_single("SELECT value FROM site_settings WHERE name = 'secure_uploads'").first == "t" + + if (secure_media_enabled || secure_uploads_enabled) && chat_uploads_enabled && + !GlobalSetting.allow_unsecure_chat_uploads + if chat_allow_uploads_value.nil? + DB.exec( + " + INSERT INTO site_settings(name, data_type, value, created_at, updated_at) + VALUES('chat_allow_uploads', 5, 'f', NOW(), NOW()) + ", + ) + else + DB.exec("UPDATE site_settings SET value = 'f' WHERE name = 'chat_allow_uploads'") + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb b/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb new file mode 100644 index 00000000000..b62fee32541 --- /dev/null +++ b/plugins/chat/db/migrate/20220901034107_add_user_count_stale_to_channel.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUserCountStaleToChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :user_count_stale, :boolean, default: false, null: false + end +end diff --git a/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb b/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb new file mode 100644 index 00000000000..236c1044f0a --- /dev/null +++ b/plugins/chat/db/migrate/20221005143622_add_type_to_chat_channel.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTypeToChatChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :type, :string + end +end diff --git a/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb b/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb new file mode 100644 index 00000000000..ac9aa99814c --- /dev/null +++ b/plugins/chat/db/migrate/20221014005208_add_slug_column_to_chat_channel.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSlugColumnToChatChannel < ActiveRecord::Migration[7.0] + def change + add_column :chat_channels, :slug, :string + + add_index :chat_channels, :slug + end +end diff --git a/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb b/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb new file mode 100644 index 00000000000..ed4a829d4af --- /dev/null +++ b/plugins/chat/db/post_migrate/20220104051326_change_chat_channels_timestamp_columns_to_timestamp_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ChangeChatChannelsTimestampColumnsToTimestampType < ActiveRecord::Migration[6.1] + def change + change_column_default :chat_channels, :created_at, nil + change_column_default :chat_channels, :updated_at, nil + + # the earlier AddTimestampsToChatChannels migration has been modified, + # originally it added the columns as :datetime types, now it has been + # changed to use the correct :timestamp type, this exists check is here so + # we only try and make this change on old tables created before + if !column_exists?(:chat_channels, :created_at, :timestamp) + change_column :chat_channels, :created_at, :timestamp + end + if !column_exists?(:chat_channels, :updated_at, :timestamp) + change_column :chat_channels, :updated_at, :timestamp + end + end +end diff --git a/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb b/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb new file mode 100644 index 00000000000..ca6d3b7957d --- /dev/null +++ b/plugins/chat/db/post_migrate/20220321235638_drop_chat_message_post_connections_table.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "migration/table_dropper" + +class DropChatMessagePostConnectionsTable < ActiveRecord::Migration[6.1] + def up + Migration::TableDropper.execute_drop("chat_message_post_connections") + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb b/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb new file mode 100644 index 00000000000..6ca1c406b8c --- /dev/null +++ b/plugins/chat/db/post_migrate/20220504080457_drop_old_chat_message_post_id_action_code_columns.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DropOldChatMessagePostIdActionCodeColumns < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { chat_messages: %i[post_id action_code] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb b/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb new file mode 100644 index 00000000000..4daf38ae0b7 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220516142658_remove_email_statuses_table.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RemoveEmailStatusesTable < ActiveRecord::Migration[7.0] + def up + remove_index :chat_message_email_statuses, :status + remove_index :chat_message_email_statuses, %i[user_id chat_message_id] + + Migration::TableDropper.execute_drop("chat_message_email_statuses") + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb b/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb new file mode 100644 index 00000000000..d55b9eec23e --- /dev/null +++ b/plugins/chat/db/post_migrate/20220518180642_remove_user_option_last_emailed_at.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveUserOptionLastEmailedAt < ActiveRecord::Migration[7.0] + def change + remove_column :user_options, :last_emailed_for_chat, :datetime + end +end diff --git a/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb b/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb new file mode 100644 index 00000000000..afcee809a4f --- /dev/null +++ b/plugins/chat/db/post_migrate/20220526135414_remove_corrupted_last_read_message_id.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class RemoveCorruptedLastReadMessageId < ActiveRecord::Migration[7.0] + def down + raise ActiveRecord::IrreversibleMigration + end + + def up + # Delete memberships for deleted channels + execute <<~SQL + DELETE FROM user_chat_channel_memberships uccm + WHERE NOT EXISTS ( + SELECT FROM chat_channels cc + WHERE cc.id = uccm.chat_channel_id + ); + SQL + + # Delete messages for deleted channels + execute <<~SQL + DELETE FROM chat_messages cm + WHERE NOT EXISTS ( + SELECT FROM chat_channels cc + WHERE cc.id = cm.chat_channel_id + ); + SQL + + # Reset highest_channel_message_id if the message cannot be found in the channel + execute <<~SQL + WITH highest_channel_message_id AS ( + SELECT chat_channel_id, max(chat_messages.id) as highest_id + FROM chat_messages + GROUP BY chat_channel_id + ) + UPDATE user_chat_channel_memberships uccm + SET last_read_message_id = highest_channel_message_id.highest_id + FROM highest_channel_message_id + WHERE highest_channel_message_id.chat_channel_id = uccm.chat_channel_id + AND uccm.last_read_message_id IS NOT NULL + AND uccm.last_read_message_id NOT IN ( + SELECT id FROM chat_messages WHERE chat_messages.chat_channel_id = uccm.chat_channel_id + ) + SQL + + # Nullify in_reply_to where message is deleted + execute <<~SQL + UPDATE chat_messages cm + SET in_reply_to_id = NULL + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm2 + WHERE cm.in_reply_to_id = cm2.id + ); + SQL + + # Delete chat_message_revisions with no message linked + execute <<~SQL + DELETE FROM chat_message_revisions cmr + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cmr.chat_message_id + ); + SQL + + # Delete chat_message_reactions with no message linked + execute <<~SQL + DELETE FROM chat_message_reactions cmr + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cmr.chat_message_id + ); + SQL + + # Delete bookmarks with no message linked + execute <<~SQL + DELETE FROM bookmarks b + WHERE b.bookmarkable_type = 'ChatMessage' + AND NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = b.bookmarkable_id + ); + SQL + + # Delete chat_mention with no message linked + execute <<~SQL + DELETE FROM chat_mentions + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = chat_mentions.chat_message_id + ); + SQL + + # Delete chat_webhook_event with no message linked + execute <<~SQL + DELETE FROM chat_webhook_events cwe + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = cwe.chat_message_id + ); + SQL + + # Delete chat_uploads with no message linked + execute <<~SQL + DELETE FROM chat_uploads + WHERE NOT EXISTS ( + SELECT FROM chat_messages cm + WHERE cm.id = chat_uploads.chat_message_id + ); + SQL + end +end diff --git a/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb b/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb new file mode 100644 index 00000000000..b746266d85f --- /dev/null +++ b/plugins/chat/db/post_migrate/20220531105951_drop_user_chat_channel_last_reads.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "migration/table_dropper" + +# usage has been dropped in https://github.com/discourse/discourse-chat/commit/1c110b71b28411dc7ac3ab9e3950e0bbf38d7970 +# but table never got dropped +class DropUserChatChannelLastReads < ActiveRecord::Migration[7.0] + DROPPED_TABLES ||= %i[user_chat_channel_last_reads] + + def up + DROPPED_TABLES.each { |table| Migration::TableDropper.execute_drop(table) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb b/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb new file mode 100644 index 00000000000..0a41149418e --- /dev/null +++ b/plugins/chat/db/post_migrate/20220630074200_drop_chat_isolated_from_user_options.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DropChatIsolatedFromUserOptions < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { user_options: %i[chat_isolated] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb b/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb new file mode 100644 index 00000000000..05e61f5f828 --- /dev/null +++ b/plugins/chat/db/post_migrate/20220701195731_convert_chatable_topics_to_categories.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ConvertChatableTopicsToCategories < ActiveRecord::Migration[7.0] + def up + # convert chatable topics to categories using topic's category_id or default category + DB.exec(<<~SQL, uncategorized_category_id: SiteSetting.uncategorized_category_id) + UPDATE chat_channels cc + SET (chatable_type, chatable_id, name) = ( + SELECT 'Category', coalesce(t.category_id, :uncategorized_category_id), coalesce(cc.name, t.title) + FROM topics t + WHERE cc.chatable_id = t.id + ) + WHERE cc.chatable_type = 'Topic' + SQL + + # soft delete all posts small actions + DB.exec( + "UPDATE posts SET deleted_at = :deleted_at, deleted_by_id = :deleted_by_id WHERE action_code IN (:action_codes)", + action_codes: %w[chat.enabled chat.disabled], + deleted_at: Time.zone.now, + deleted_by_id: Discourse::SYSTEM_USER_ID, + ) + + # removes all chat custom fields + DB.exec(<<~SQL) + DELETE FROM topic_custom_fields + WHERE name = 'has_chat_enabled' + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb b/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb new file mode 100644 index 00000000000..bab35b96f7c --- /dev/null +++ b/plugins/chat/db/post_migrate/20221004122254_delete_reviewables_targetting_deleted_chat_messages.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class DeleteReviewablesTargettingDeletedChatMessages < ActiveRecord::Migration[7.0] + def down + raise ActiveRecord::IrreversibleMigration + end + + def up + deleted_ids = DB.query_single <<~SQL + DELETE FROM reviewables r + WHERE r.type = 'ReviewableChatMessage' + AND r.id IN ( + SELECT raux.id + FROM reviewables raux + LEFT OUTER JOIN chat_messages cm ON cm.id = raux.target_id + WHERE raux.type = 'ReviewableChatMessage' AND cm.id IS NULL + ) + RETURNING r.id + SQL + + if deleted_ids + DB.exec(<<~SQL, deleted_ids: deleted_ids) + DELETE FROM reviewable_scores rs + WHERE rs.reviewable_id IN (:deleted_ids) + SQL + + DB.exec(<<~SQL, deleted_ids: deleted_ids) + DELETE FROM reviewable_histories rh + WHERE rh.reviewable_id IN (:deleted_ids) + SQL + end + end +end diff --git a/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb b/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb new file mode 100644 index 00000000000..8a879b3a55c --- /dev/null +++ b/plugins/chat/db/post_migrate/20221018091412_migrate_chat_channels.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class MigrateChatChannels < ActiveRecord::Migration[7.0] + def up + DB.exec("UPDATE chat_channels SET type='CategoryChannel' WHERE chatable_type = 'Category'") + DB.exec( + "UPDATE chat_channels SET type='DMChannel' WHERE chatable_type = 'DirectMessageChannel'", + ) + end +end diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb new file mode 100644 index 00000000000..8d1e0e65e02 --- /dev/null +++ b/plugins/chat/lib/chat_channel_archive_service.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +## +# From time to time, site admins may choose to sunset a chat channel and archive +# the messages within. The main use case for this is a topic-based channel, but +# it can be used for category channels just fine. It cannot be used for DM channels +# in its current iteration. +# +# To archive a channel, we mark it read_only first to prevent any further message +# additions or changes, and create a record to track whether the archive topic +# will be new or existing. When we archive the channel, messages are copied into +# posts in batches using the [chat] BBCode to quote the messages. The messages are +# deleted once the batch has its post made. The execute action of this class is +# idempotent, so if we fail halfway through the archive process it can be run again. +# +# Once all of the messages have been copied then we mark the channel as archived. +class Chat::ChatChannelArchiveService + ARCHIVED_MESSAGES_PER_POST = 100 + + def self.begin_archive_process(chat_channel:, acting_user:, topic_params:) + return if ChatChannelArchive.exists?(chat_channel: chat_channel) + + ChatChannelArchive.transaction do + chat_channel.read_only!(acting_user) + + archive = + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: acting_user, + total_messages: chat_channel.chat_messages.count, + destination_topic_id: topic_params[:topic_id], + destination_topic_title: topic_params[:topic_title], + destination_category_id: topic_params[:category_id], + destination_tags: topic_params[:tags], + ) + Jobs.enqueue(:chat_channel_archive, chat_channel_archive_id: archive.id) + + archive + end + end + + def self.retry_archive_process(chat_channel:) + return if !chat_channel.chat_channel_archive&.failed? + Jobs.enqueue( + :chat_channel_archive, + chat_channel_archive_id: chat_channel.chat_channel_archive.id, + ) + end + + attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title + + def initialize(chat_channel_archive) + @chat_channel_archive = chat_channel_archive + @chat_channel = chat_channel_archive.chat_channel + @chat_channel_title = chat_channel.title(chat_channel_archive.archived_by) + end + + def execute + chat_channel_archive.update(archive_error: nil) + + begin + ensure_destination_topic_exists! + + Rails.logger.info( + "Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).", + ) + + # a batch should be idempotent, either the post is created and the + # messages are deleted or we roll back the whole thing. + # + # at some point we may want to reconsider disabling post validations, + # and add in things like dynamic resizing of the number of messages per + # post based on post length, but that can be done later + # + # another future improvement is to send a MessageBus message for each + # completed batch, so the UI can receive updates and show a progress + # bar or something similar + chat_channel + .chat_messages + .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| + create_post( + ChatTranscriptService.new( + chat_channel, + chat_channel_archive.archived_by, + messages_or_ids: chat_messages, + opts: { + no_link: true, + include_reactions: true, + }, + ).generate_markdown, + ) { delete_message_batch(chat_messages.map(&:id)) } + end + + kick_all_users + complete_archive + rescue => err + notify_archiver(:failed, error: err) + raise err + end + end + + private + + def create_post(raw) + pc = nil + Post.transaction do + pc = + PostCreator.new( + Discourse.system_user, + raw: raw, + # we must skip these because the posts are created in a big transaction, + # we do them all at the end instead + skip_jobs: true, + # we do not want to be sending out notifications etc. from this + # automatic background process + import_mode: true, + # don't want to be stopped by watched word or post length validations + skip_validations: true, + topic_id: chat_channel_archive.destination_topic_id, + ) + + pc.create + + # so we can also delete chat messages in the same transaction + yield if block_given? + end + pc.enqueue_jobs + end + + def ensure_destination_topic_exists! + if !chat_channel_archive.destination_topic.present? + Rails.logger.info("Creating topic for #{chat_channel_title} archive.") + Topic.transaction do + topic_creator = + TopicCreator.new( + Discourse.system_user, + Guardian.new(chat_channel_archive.archived_by), + { + title: chat_channel_archive.destination_topic_title, + category: chat_channel_archive.destination_category_id, + tags: chat_channel_archive.destination_tags, + import_mode: true, + }, + ) + + chat_channel_archive.update!(destination_topic: topic_creator.create) + end + + Rails.logger.info("Creating first post for #{chat_channel_title} archive.") + create_post( + I18n.t( + "chat.channel.archive.first_post_raw", + channel_name: chat_channel_title, + channel_url: chat_channel.url, + ), + ) + else + Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") + end + + update_destination_topic_status + end + + def update_destination_topic_status + # we only want to do this when the destination topic is new, not an + # existing topic, because we don't want to update the status unexpectedly + # on an existing topic + if chat_channel_archive.destination_topic_title.present? + if SiteSetting.chat_archive_destination_topic_status == "archived" + chat_channel_archive.destination_topic.update!(archived: true) + elsif SiteSetting.chat_archive_destination_topic_status == "closed" + chat_channel_archive.destination_topic.update!(closed: true) + end + end + end + + def delete_message_batch(message_ids) + ChatMessage.transaction do + ChatMessage.where(id: message_ids).update_all( + deleted_at: DateTime.now, + deleted_by_id: chat_channel_archive.archived_by.id, + ) + + chat_channel_archive.update!( + archived_messages: chat_channel_archive.archived_messages + message_ids.length, + ) + end + + Rails.logger.info( + "Archived #{chat_channel_archive.archived_messages} messages for #{chat_channel_title} archive.", + ) + end + + def complete_archive + Rails.logger.info("Creating posts completed for #{chat_channel_title} archive.") + chat_channel.archived!(chat_channel_archive.archived_by) + notify_archiver(:success) + end + + def notify_archiver(result, error: nil) + base_translation_params = { + channel_name: chat_channel_title, + topic_title: chat_channel_archive.destination_topic.title, + topic_url: chat_channel_archive.destination_topic.url, + } + + if result == :failed + Discourse.warn_exception( + error, + message: "Error when archiving chat channel #{chat_channel_title}.", + env: { + chat_channel_id: chat_channel.id, + chat_channel_name: chat_channel_title, + }, + ) + error_translation_params = + base_translation_params.merge( + channel_url: chat_channel.url, + messages_archived: chat_channel_archive.archived_messages, + ) + chat_channel_archive.update(archive_error: error.message) + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_failed, + error_translation_params, + ) + else + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_complete, + base_translation_params, + ) + end + + ChatPublisher.publish_archive_status( + chat_channel, + archive_status: result, + archived_messages: chat_channel_archive.archived_messages, + archive_topic_id: chat_channel_archive.destination_topic_id, + total_messages: chat_channel_archive.total_messages, + ) + end + + def kick_all_users + Chat::ChatChannelMembershipManager.new(chat_channel).unfollow_all_users + end +end diff --git a/plugins/chat/lib/chat_channel_fetcher.rb b/plugins/chat/lib/chat_channel_fetcher.rb new file mode 100644 index 00000000000..30cb34e8ee7 --- /dev/null +++ b/plugins/chat/lib/chat_channel_fetcher.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +module Chat::ChatChannelFetcher + MAX_PUBLIC_CHANNEL_RESULTS = 50 + + def self.structured(guardian) + memberships = Chat::ChatChannelMembershipManager.all_for_user(guardian.user) + { + public_channels: + secured_public_channels(guardian, memberships, status: :open, following: true), + direct_message_channels: + secured_direct_message_channels(guardian.user.id, memberships, guardian), + memberships: memberships, + } + end + + def self.all_secured_channel_ids(guardian, following: true) + allowed_channel_ids_sql = generate_allowed_channel_ids_sql(guardian) + + return DB.query_single(allowed_channel_ids_sql) if !following + + DB.query_single(<<~SQL, user_id: guardian.user.id) + SELECT chat_channel_id + FROM user_chat_channel_memberships + WHERE user_chat_channel_memberships.user_id = :user_id + AND user_chat_channel_memberships.chat_channel_id IN ( + #{allowed_channel_ids_sql} + ) + SQL + end + + def self.generate_allowed_channel_ids_sql(guardian) + <<~SQL + -- secured category chat channels + #{ + ChatChannel + .select(:id) + .joins( + "INNER JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where( + "categories.id IN (:allowed_category_ids)", + allowed_category_ids: guardian.allowed_category_ids, + ) + .to_sql + } + + UNION + + -- secured direct message chat channels + #{ + ChatChannel + .select(:id) + .joins( + "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id + AND chat_channels.chatable_type = 'DirectMessageChannel' + INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id", + ) + .where("direct_message_users.user_id = :user_id", user_id: guardian.user.id) + .to_sql + } + SQL + end + + def self.secured_public_channel_search(guardian, options = {}) + channels = + ChatChannel + .includes(:chat_channel_archive) + .includes(chatable: [:topic_only_relative_url]) + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: ChatChannel.public_channel_chatable_types) + .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") + + channels = channels.where(status: options[:status]) if options[:status].present? + + if options[:filter].present? + sql = "chat_channels.name ILIKE :filter OR categories.name ILIKE :filter" + channels = + channels.where(sql, filter: "%#{options[:filter].downcase}%").order( + "chat_channels.name ASC, categories.name ASC", + ) + end + + if options.key?(:following) + if options[:following] + channels = + channels.joins(:user_chat_channel_memberships).where( + user_chat_channel_memberships: { + user_id: guardian.user.id, + following: true, + }, + ) + else + channels = + channels.where( + "chat_channels.id NOT IN (SELECT chat_channel_id FROM user_chat_channel_memberships uccm WHERE uccm.chat_channel_id = chat_channels.id AND following IS TRUE AND user_id = ?)", + guardian.user.id, + ) + end + end + + options[:limit] = (options[:limit] || MAX_PUBLIC_CHANNEL_RESULTS).to_i.clamp( + 1, + MAX_PUBLIC_CHANNEL_RESULTS, + ) + options[:offset] = [options[:offset].to_i, 0].max + + channels.limit(options[:limit]).offset(options[:offset]) + end + + def self.secured_public_channels(guardian, memberships, options = { following: true }) + channels = secured_public_channel_search(guardian, options) + decorate_memberships_with_tracking_data(guardian, channels, memberships) + channels = channels.to_a + preload_custom_fields_for(channels) + channels + end + + def self.preload_custom_fields_for(channels) + preload_fields = Category.instance_variable_get(:@custom_field_types).keys + Category.preload_custom_fields( + channels.select { |c| c.chatable_type == "Category" }.map(&:chatable), + preload_fields, + ) + end + + def self.secured_direct_message_channels(user_id, memberships, guardian) + query = ChatChannel.includes(chatable: [{ direct_message_users: :user }, :users]) + query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status + + channels = + query + .joins(:user_chat_channel_memberships) + .where(user_chat_channel_memberships: { user_id: user_id, following: true }) + .where(chatable_type: "DirectMessageChannel") + .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") + .order(last_message_sent_at: :desc) + .to_a + + preload_fields = + User.allowed_user_custom_fields(guardian) + + UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" } + User.preload_custom_fields(channels.map { |c| c.chatable.users }.flatten, preload_fields) + + decorate_memberships_with_tracking_data(guardian, channels, memberships) + end + + def self.decorate_memberships_with_tracking_data(guardian, channels, memberships) + unread_counts_per_channel = unread_counts(channels, guardian.user.id) + + mention_notifications = + Notification.unread.where( + user_id: guardian.user.id, + notification_type: Notification.types[:chat_mention], + ) + mention_notification_data = mention_notifications.map { |m| JSON.parse(m.data) } + + channels.each do |channel| + membership = memberships.find { |m| m.chat_channel_id == channel.id } + + if membership + membership.unread_mentions = + mention_notification_data.count do |data| + data["chat_channel_id"] == channel.id && + data["chat_message_id"] > (membership.last_read_message_id || 0) + end + + membership.unread_count = unread_counts_per_channel[channel.id] if !membership.muted + end + end + end + + def self.unread_counts(channels, user_id) + unread_counts = DB.query_array(<<~SQL, channel_ids: channels.map(&:id), user_id: user_id).to_h + SELECT cc.id, COUNT(*) as count + FROM chat_messages cm + JOIN chat_channels cc ON cc.id = cm.chat_channel_id + JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = cc.id + WHERE cc.id IN (:channel_ids) + AND cm.user_id != :user_id + AND uccm.user_id = :user_id + AND cm.id > COALESCE(uccm.last_read_message_id, 0) + AND cm.deleted_at IS NULL + GROUP BY cc.id + SQL + unread_counts.default = 0 + unread_counts + end + + def self.find_with_access_check(channel_id_or_name, guardian) + begin + channel_id_or_name = Integer(channel_id_or_name) + rescue ArgumentError + end + + base_channel_relation = + ChatChannel.includes(:chatable).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + + if guardian.user.staff? + base_channel_relation = base_channel_relation.includes(:chat_channel_archive) + end + + if channel_id_or_name.is_a? Integer + chat_channel = base_channel_relation.find_by(id: channel_id_or_name) + else + chat_channel = + base_channel_relation.find_by( + "LOWER(categories.name) = :name OR LOWER(chat_channels.name) = :name", + name: channel_id_or_name.downcase, + ) + end + + raise Discourse::NotFound if chat_channel.blank? + raise Discourse::InvalidAccess if !guardian.can_see_chat_channel?(chat_channel) + chat_channel + end +end diff --git a/plugins/chat/lib/chat_channel_membership_manager.rb b/plugins/chat/lib/chat_channel_membership_manager.rb new file mode 100644 index 00000000000..5947f23d7a4 --- /dev/null +++ b/plugins/chat/lib/chat_channel_membership_manager.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class Chat::ChatChannelMembershipManager + def self.all_for_user(user) + UserChatChannelMembership.where(user: user) + end + + attr_reader :channel + + def initialize(channel) + @channel = channel + end + + def find_for_user(user, following: nil) + params = { user_id: user.id, chat_channel_id: channel.id } + params[:following] = following if following.present? + + UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) + end + + def follow(user) + membership = + find_for_user(user) || + UserChatChannelMembership.new(user: user, chat_channel: channel, following: true) + + ActiveRecord::Base.transaction do + if membership.new_record? + membership.save! + recalculate_user_count + elsif !membership.following + membership.update!(following: true) + recalculate_user_count + end + end + + membership + end + + def unfollow(user) + membership = find_for_user(user) + + return if membership.blank? + + ActiveRecord::Base.transaction do + if membership.following + membership.update!(following: false) + recalculate_user_count + end + end + + membership + end + + def recalculate_user_count + return if ChatChannel.exists?(id: channel.id, user_count_stale: true) + channel.update!(user_count_stale: true) + Jobs.enqueue_in(3.seconds, :update_channel_user_count, chat_channel_id: channel.id) + end + + def unfollow_all_users + UserChatChannelMembership.where(chat_channel: channel).update_all( + following: false, + last_read_message_id: channel.chat_messages.last&.id, + ) + end + + def enforce_automatic_channel_memberships + Jobs.enqueue(:auto_manage_channel_memberships, chat_channel_id: channel.id) + end + + def enforce_automatic_user_membership(user) + Jobs.enqueue( + :auto_join_channel_batch, + chat_channel_id: channel.id, + starts_at: user.id, + ends_at: user.id, + ) + end +end diff --git a/plugins/chat/lib/chat_mailer.rb b/plugins/chat/lib/chat_mailer.rb new file mode 100644 index 00000000000..a40df7f3b54 --- /dev/null +++ b/plugins/chat/lib/chat_mailer.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Chat::ChatMailer + def self.send_unread_mentions_summary + return unless SiteSetting.chat_enabled + + users_with_unprocessed_unread_mentions.find_each do |user| + # user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]] + # Find the max unread id per membership. + membership_and_max_unread_mention_ids = + user + .memberships_with_unread_messages + .group_by { |memberships| memberships[0] } + .transform_values do |membership_and_msg_ids| + membership_and_msg_ids.max_by { |membership, msg| msg } + end + .values + + Jobs.enqueue( + :user_email, + type: "chat_summary", + user_id: user.id, + force_respect_seen_recently: true, + memberships_to_update_data: membership_and_max_unread_mention_ids, + ) + end + end + + private + + def self.users_with_unprocessed_unread_mentions + when_away_frequency = UserOption.chat_email_frequencies[:when_away] + allowed_group_ids = Chat.allowed_group_ids + + User + .select("users.id", "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages") + .joins(:user_option) + .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) + .where("users.last_seen_at < ?", 15.minutes.ago) + .joins(:groups) + .where(groups: { id: allowed_group_ids }) + .joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id") + .joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id") + .joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id") + .where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id") + .where("c_msg.created_at > ?", 1.week.ago) + .where(<<~SQL) + (uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR + (cc.chatable_type = 'DirectMessageChannel') + ) + SQL + .group("users.id, uccm.user_id") + end +end diff --git a/plugins/chat/lib/chat_message_bookmarkable.rb b/plugins/chat/lib/chat_message_bookmarkable.rb new file mode 100644 index 00000000000..8a72d0f9f25 --- /dev/null +++ b/plugins/chat/lib/chat_message_bookmarkable.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ChatMessageBookmarkable < BaseBookmarkable + def self.model + ChatMessage + end + + def self.serializer + UserChatMessageBookmarkSerializer + end + + def self.preload_associations + [:chat_channel] + end + + def self.list_query(user, guardian) + accessible_channel_ids = Chat::ChatChannelFetcher.all_secured_channel_ids(guardian) + return if accessible_channel_ids.empty? + user + .bookmarks_of_type("ChatMessage") + .joins( + "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id + AND chat_messages.deleted_at IS NULL + AND bookmarks.bookmarkable_type = 'ChatMessage'", + ) + .where("chat_messages.chat_channel_id IN (?)", accessible_channel_ids) + end + + def self.search_query(bookmarks, query, ts_query, &bookmarkable_search) + bookmarkable_search.call(bookmarks, "chat_messages.message ILIKE :q") + end + + def self.validate_before_create(guardian, bookmarkable) + if bookmarkable.blank? || !guardian.can_see_chat_channel?(bookmarkable.chat_channel) + raise Discourse::InvalidAccess + end + end + + def self.reminder_handler(bookmark) + send_reminder_notification( + bookmark, + data: { + title: + I18n.t( + "chat.bookmarkable.notification_title", + channel_name: bookmark.bookmarkable.chat_channel.title(bookmark.user), + ), + bookmarkable_url: bookmark.bookmarkable.url, + }, + ) + end + + def self.reminder_conditions(bookmark) + bookmark.bookmarkable.present? && bookmark.bookmarkable.chat_channel.present? + end + + def self.can_see?(guardian, bookmark) + guardian.can_see_chat_channel?(bookmark.bookmarkable.chat_channel) + end + + def self.cleanup_deleted + DB.query(<<~SQL, grace_time: 3.days.ago) + DELETE FROM bookmarks b + USING chat_messages cm + WHERE b.bookmarkable_id = cm.id AND b.bookmarkable_type = 'ChatMessage' + AND (cm.deleted_at < :grace_time) + SQL + end +end diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb new file mode 100644 index 00000000000..6d4667cbe6d --- /dev/null +++ b/plugins/chat/lib/chat_message_creator.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +class Chat::ChatMessageCreator + attr_reader :error, :chat_message + + def self.create(opts) + instance = new(**opts) + instance.create + instance + end + + def initialize( + chat_channel:, + in_reply_to_id: nil, + user:, + content:, + staged_id: nil, + incoming_chat_webhook: nil, + upload_ids: nil + ) + @chat_channel = chat_channel + @user = user + @guardian = Guardian.new(user) + @in_reply_to_id = in_reply_to_id + @content = content + @staged_id = staged_id + @incoming_chat_webhook = incoming_chat_webhook + @upload_ids = upload_ids || [] + @error = nil + + @chat_message = + ChatMessage.new( + chat_channel: @chat_channel, + user_id: @user.id, + in_reply_to_id: @in_reply_to_id, + message: @content, + ) + end + + def create + begin + validate_channel_status! + uploads = get_uploads + validate_message!(has_uploads: uploads.any?) + @chat_message.cook + @chat_message.save! + create_chat_webhook_event + @chat_message.attach_uploads(uploads) + ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all + ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id) + Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) + Chat::ChatNotifier.notify_new( + chat_message: @chat_message, + timestamp: @chat_message.created_at, + ) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + + if @chat_channel.direct_message_channel? && !@guardian.can_create_direct_message? + raise StandardError.new(I18n.t("chat.errors.user_cannot_send_direct_messages")) + else + raise StandardError.new( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: @chat_channel.status_name, + ), + ) + end + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def create_chat_webhook_event + return if @incoming_chat_webhook.blank? + ChatWebhookEvent.create( + chat_message: @chat_message, + incoming_chat_webhook: @incoming_chat_webhook, + ) + end + + def get_uploads + return [] if @upload_ids.blank? || !SiteSetting.chat_allow_uploads + + Upload.where(id: @upload_ids, user_id: @user.id) + end +end diff --git a/plugins/chat/lib/chat_message_processor.rb b/plugins/chat/lib/chat_message_processor.rb new file mode 100644 index 00000000000..078e73cf0a0 --- /dev/null +++ b/plugins/chat/lib/chat_message_processor.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Chat::ChatMessageProcessor + include ::CookedProcessorMixin + + def initialize(chat_message) + @model = chat_message + @previous_cooked = (chat_message.cooked || "").dup + @with_secure_uploads = false + @size_cache = {} + @opts = {} + + cooked = ChatMessage.cook(chat_message.message) + @doc = Loofah.fragment(cooked) + end + + def run! + post_process_oneboxes + DiscourseEvent.trigger(:chat_message_processed, @doc, @model) + end + + def large_images + [] + end + + def broken_images + [] + end + + def downloaded_images + {} + end +end diff --git a/plugins/chat/lib/chat_message_rate_limiter.rb b/plugins/chat/lib/chat_message_rate_limiter.rb new file mode 100644 index 00000000000..9f205098e7b --- /dev/null +++ b/plugins/chat/lib/chat_message_rate_limiter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Chat::ChatMessageRateLimiter + def self.run!(user) + instance = self.new(user) + instance.run! + end + + def initialize(user) + @user = user + end + + def run! + return if @user.staff? + + allowed_message_count = + ( + if @user.trust_level == TrustLevel[0] + SiteSetting.chat_allowed_messages_for_trust_level_0 + else + SiteSetting.chat_allowed_messages_for_other_trust_levels + end + ) + return if allowed_message_count.zero? + + @rate_limiter = RateLimiter.new(@user, "create_chat_message", allowed_message_count, 30.seconds) + silence_user if @rate_limiter.remaining.zero? + @rate_limiter.performed! + end + + def clear! + # Used only for testing. Need to clear the rate limiter between tests. + @rate_limiter.clear! if defined?(@rate_limiter) + end + + private + + def silence_user + silenced_for_minutes = SiteSetting.chat_auto_silence_duration + return unless silenced_for_minutes > 0 + + UserSilencer.silence( + @user, + Discourse.system_user, + silenced_till: silenced_for_minutes.minutes.from_now, + reason: I18n.t("chat.errors.rate_limit_exceeded"), + ) + end +end diff --git a/plugins/chat/lib/chat_message_reactor.rb b/plugins/chat/lib/chat_message_reactor.rb new file mode 100644 index 00000000000..9304809cad8 --- /dev/null +++ b/plugins/chat/lib/chat_message_reactor.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class Chat::ChatMessageReactor + ADD_REACTION = :add + REMOVE_REACTION = :remove + MAX_REACTIONS_LIMIT = 30 + + def initialize(user, chat_channel) + @user = user + @chat_channel = chat_channel + @guardian = Guardian.new(user) + end + + def react!(message_id:, react_action:, emoji:) + @guardian.ensure_can_see_chat_channel!(@chat_channel) + @guardian.ensure_can_react! + validate_channel_status! + validate_reaction!(react_action, emoji) + message = ensure_chat_message!(message_id) + validate_max_reactions!(message, react_action, emoji) + + ActiveRecord::Base.transaction do + enforce_channel_membership! + create_reaction(message, react_action, emoji) + end + + publish_reaction(message, react_action, emoji) + + message + end + + private + + def ensure_chat_message!(message_id) + message = ChatMessage.find_by(id: message_id, chat_channel: @chat_channel) + raise Discourse::NotFound unless message + message + end + + def validate_reaction!(react_action, emoji) + if ![ADD_REACTION, REMOVE_REACTION].include?(react_action) || !Emoji.exists?(emoji) + raise Discourse::InvalidParameters + end + end + + def enforce_channel_membership! + Chat::ChatChannelMembershipManager.new(@chat_channel).follow(@user) + end + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "chat.errors.channel_modify_message_disallowed", + custom_message_params: { + status: @chat_channel.status_name, + }, + ) + end + + def validate_max_reactions!(message, react_action, emoji) + if react_action == ADD_REACTION && + message.reactions.count("DISTINCT emoji") >= MAX_REACTIONS_LIMIT && + !message.reactions.exists?(emoji: emoji) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "chat.errors.max_reactions_limit_reached", + ) + end + end + + def create_reaction(message, react_action, emoji) + if react_action == ADD_REACTION + message.reactions.find_or_create_by!(user: @user, emoji: emoji) + else + message.reactions.where(user: @user, emoji: emoji).destroy_all + end + end + + def publish_reaction(message, react_action, emoji) + ChatPublisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) + end +end diff --git a/plugins/chat/lib/chat_message_updater.rb b/plugins/chat/lib/chat_message_updater.rb new file mode 100644 index 00000000000..df67587343c --- /dev/null +++ b/plugins/chat/lib/chat_message_updater.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +class Chat::ChatMessageUpdater + attr_reader :error + + def self.update(opts) + instance = new(**opts) + instance.update + instance + end + + def initialize(chat_message:, new_content:, upload_ids: nil) + @chat_message = chat_message + @old_message_content = chat_message.message + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + @guardian = Guardian.new(@user) + @new_content = new_content + @upload_ids = upload_ids + @error = nil + end + + def update + begin + validate_channel_status! + @chat_message.message = @new_content + upload_info = get_upload_info + validate_message!(has_uploads: upload_info[:uploads].any?) + @chat_message.cook + @chat_message.save! + update_uploads(upload_info) + revision = save_revision! + ChatPublisher.publish_edit!(@chat_channel, @chat_message) + Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) + Chat::ChatNotifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_modify_channel_message?(@chat_channel) + raise StandardError.new( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: @chat_channel.status_name, + ), + ) + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def get_upload_info + return { uploads: [] } if @upload_ids.nil? || !SiteSetting.chat_allow_uploads + + uploads = Upload.where(id: @upload_ids, user_id: @user.id) + if uploads.count != @upload_ids.count + # User is passing upload_ids for uploads that they don't own. Don't change anything. + return { uploads: @chat_message.uploads, changed: false } + end + + new_upload_ids = uploads.map(&:id) + existing_upload_ids = @chat_message.upload_ids + difference = (existing_upload_ids + new_upload_ids) - (existing_upload_ids & new_upload_ids) + { uploads: uploads, changed: difference.any? } + end + + def update_uploads(upload_info) + return unless upload_info[:changed] + + ChatUpload.where(chat_message: @chat_message).destroy_all + @chat_message.attach_uploads(upload_info[:uploads]) + end + + def save_revision! + @chat_message.revisions.create!( + old_message: @old_message_content, + new_message: @chat_message.message, + ) + end +end diff --git a/plugins/chat/lib/chat_notifier.rb b/plugins/chat/lib/chat_notifier.rb new file mode 100644 index 00000000000..d2fcc4496ad --- /dev/null +++ b/plugins/chat/lib/chat_notifier.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +## +# When we are attempting to notify users based on a message we have to take +# into account the following: +# +# * Individual user mentions like @alfred +# * Group mentions that include N users such as @support +# * Global @here and @all mentions +# * Users watching the channel via UserChatChannelMembership +# +# For various reasons a mention may not notify a user: +# +# * The target user of the mention is ignoring or muting the user who created the message +# * The target user either cannot chat or cannot see the chat channel, in which case +# they are defined as `unreachable` +# * The target user is not a member of the channel, in which case they are defined +# as `welcome_to_join` +# * In the case of global @here and @all mentions users with the preference +# `ignore_channel_wide_mention` set to true will not be notified +# +# For any users that fall under the `unreachable` or `welcome_to_join` umbrellas +# we send a MessageBus message to the UI and to inform the creating user. The +# creating user can invite any `welcome_to_join` users to the channel. Target +# users who are ignoring or muting the creating user _do not_ fall into this bucket. +# +# The ignore/mute filtering is also applied via the ChatNotifyWatching job, +# which prevents desktop / push notifications being sent. +class Chat::ChatNotifier + class << self + def user_has_seen_message?(membership, chat_message_id) + (membership.last_read_message_id || 0) >= chat_message_id + end + + def push_notification_tag(type, chat_channel_id) + "#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}" + end + + def notify_edit(chat_message:, timestamp:) + new(chat_message, timestamp).notify_edit + end + + def notify_new(chat_message:, timestamp:) + new(chat_message, timestamp).notify_new + end + end + + def initialize(chat_message, timestamp) + @chat_message = chat_message + @timestamp = timestamp + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + end + + ### Public API + + def notify_new + to_notify = list_users_to_notify + inaccessible = to_notify.extract!(:unreachable, :welcome_to_join) + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + mentioned_user_ids.each do |member_id| + ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) + end + + notify_creator_of_inaccessible_mentions( + inaccessible[:unreachable], + inaccessible[:welcome_to_join], + ) + + notify_mentioned_users(to_notify) + notify_watching_users(except: mentioned_user_ids << @user.id) + + to_notify + end + + def notify_edit + existing_notifications = + ChatMention.includes(:user, :notification).where(chat_message: @chat_message) + already_notified_user_ids = existing_notifications.map(&:user_id) + + to_notify = list_users_to_notify + inaccessible = to_notify.extract!(:unreachable, :welcome_to_join) + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + needs_deletion = already_notified_user_ids - mentioned_user_ids + needs_deletion.each do |user_id| + chat_mention = existing_notifications.detect { |n| n.user_id == user_id } + chat_mention.notification.destroy! + chat_mention.destroy! + end + + needs_notification_ids = mentioned_user_ids - already_notified_user_ids + return if needs_notification_ids.blank? + + notify_creator_of_inaccessible_mentions( + inaccessible[:unreachable], + inaccessible[:welcome_to_join], + ) + + notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) + + to_notify + end + + private + + def list_users_to_notify + {}.tap do |to_notify| + # The order of these methods is the precedence + # between different mention types. + + already_covered_ids = [] + + expand_direct_mentions(to_notify, already_covered_ids) + expand_group_mentions(to_notify, already_covered_ids) + expand_here_mention(to_notify, already_covered_ids) + expand_global_mention(to_notify, already_covered_ids) + + filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + + to_notify[:all_mentioned_user_ids] = already_covered_ids + end + end + + def chat_users + users = + User.includes(:do_not_disturb_timings, :push_subscriptions, :user_chat_channel_memberships) + + users + .distinct + .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins(:user_option) + .real + .not_suspended + .where(user_options: { chat_enabled: true }) + .where.not(username_lower: @user.username.downcase) + end + + def rest_of_the_channel + chat_users.where( + user_chat_channel_memberships: { + following: true, + chat_channel_id: @chat_channel.id, + }, + ) + end + + def members_accepting_channel_wide_notifications + rest_of_the_channel.where(user_options: { ignore_channel_wide_mention: [false, nil] }) + end + + def direct_mentions_from_cooked + @direct_mentions_from_cooked ||= + Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention").map(&:text) + end + + def normalized_mentions(mentions) + mentions.reduce([]) do |memo, mention| + %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) + end + end + + def expand_global_mention(to_notify, already_covered_ids) + typed_global_mention = direct_mentions_from_cooked.include?("@all") + + if typed_global_mention + to_notify[:global_mentions] = members_accepting_channel_wide_notifications + .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:global_mentions]) + else + to_notify[:global_mentions] = [] + end + end + + def expand_here_mention(to_notify, already_covered_ids) + typed_here_mention = direct_mentions_from_cooked.include?("@here") + + if typed_here_mention + to_notify[:here_mentions] = members_accepting_channel_wide_notifications + .where("last_seen_at > ?", 5.minutes.ago) + .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:here_mentions]) + else + to_notify[:here_mentions] = [] + end + end + + def group_users_to_notify(users) + potential_participants, unreachable = + users.partition do |user| + guardian = Guardian.new(user) + guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel) + end + + participants, welcome_to_join = + potential_participants.partition do |participant| + participant.user_chat_channel_memberships.any? do |m| + predicate = m.chat_channel_id == @chat_channel.id + predicate = predicate && m.following == true if @chat_channel.public_channel? + predicate + end + end + + { + already_participating: participants || [], + welcome_to_join: welcome_to_join || [], + unreachable: unreachable || [], + } + end + + def expand_direct_mentions(to_notify, already_covered_ids) + direct_mentions = + chat_users + .where(username_lower: normalized_mentions(direct_mentions_from_cooked)) + .where.not(id: already_covered_ids) + + grouped = group_users_to_notify(direct_mentions) + + to_notify[:direct_mentions] = grouped[:already_participating].map(&:id) + to_notify[:welcome_to_join] = grouped[:welcome_to_join] + to_notify[:unreachable] = grouped[:unreachable] + already_covered_ids.concat(to_notify[:direct_mentions]) + end + + def group_name_mentions + @group_mentions_from_cooked ||= + normalized_mentions( + Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention-group").map(&:text), + ) + end + + def mentionable_groups + @mentionable_groups ||= + Group.mentionable(@user, include_public: false).where( + "LOWER(name) IN (?)", + group_name_mentions, + ) + end + + def expand_group_mentions(to_notify, already_covered_ids) + return [] if mentionable_groups.empty? + + mentionable_groups.each { |g| to_notify[g.name.downcase] = [] } + + reached_by_group = + chat_users.joins(:groups).where(groups: mentionable_groups).where.not(id: already_covered_ids) + + grouped = group_users_to_notify(reached_by_group) + + grouped[:already_participating].each do |user| + # When a user is a member of multiple mentioned groups, + # the most far to the left should take precedence. + ordered_group_names = group_name_mentions & mentionable_groups.map { |mg| mg.name.downcase } + user_group_names = user.groups.map { |ug| ug.name.downcase } + group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) } + + to_notify[group_name] << user.id + end + already_covered_ids.concat(grouped[:already_participating]) + + to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join]) + to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable]) + end + + def notify_creator_of_inaccessible_mentions(unreachable, welcome_to_join) + return if unreachable.empty? && welcome_to_join.empty? + + ChatPublisher.publish_inaccessible_mentions( + @user.id, + @chat_message, + unreachable, + welcome_to_join, + ) + end + + # Filters out users from global, here, group, and direct mentions that are + # ignoring or muting the creator of the message, so they will not receive + # a notification via the ChatNotifyMentioned job and are not prompted for + # invitation by the creator. + # + # already_covered_ids and to_notify sometimes contain IDs and sometimes contain + # Users, hence the gymnastics to resolve the user_id + def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + user_ids_to_screen = + already_covered_ids + .map { |ac| user_id_resolver(ac) } + .concat(to_notify.values.flatten.map { |tn| user_id_resolver(tn) }) + .uniq + screener = UserCommScreener.new(acting_user: @user, target_user_ids: user_ids_to_screen) + to_notify + .except(:unreachable) + .each do |key, users_or_ids| + to_notify[key] = users_or_ids.reject do |user_or_id| + screener.ignoring_or_muting_actor?(user_id_resolver(user_or_id)) + end + end + already_covered_ids.reject! do |already_covered| + screener.ignoring_or_muting_actor?(user_id_resolver(already_covered)) + end + end + + def user_id_resolver(obj) + obj.is_a?(User) ? obj.id : obj + end + + def notify_mentioned_users(to_notify, already_notified_user_ids: []) + Jobs.enqueue( + :chat_notify_mentioned, + { + chat_message_id: @chat_message.id, + to_notify_ids_map: to_notify.as_json, + already_notified_user_ids: already_notified_user_ids, + timestamp: @timestamp.iso8601(6), + }, + ) + end + + def notify_watching_users(except: []) + Jobs.enqueue( + :chat_notify_watching, + { + chat_message_id: @chat_message.id, + except_user_ids: except, + timestamp: @timestamp.iso8601(6), + }, + ) + end +end diff --git a/plugins/chat/lib/chat_review_queue.rb b/plugins/chat/lib/chat_review_queue.rb new file mode 100644 index 00000000000..4b0392e1511 --- /dev/null +++ b/plugins/chat/lib/chat_review_queue.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +# Acceptable options: +# - message: Used when the flag type is notify_user or notify_moderators and we have to create +# a separate PM. +# - is_warning: Staff can send warnings when using the notify_user flag. +# - take_action: Automatically approves the created reviewable and deletes the chat message. +# - queue_for_review: Adds a special reason to the reviwable score and creates the reviewable using +# the force_review option. + +class Chat::ChatReviewQueue + def flag_message(chat_message, guardian, flag_type_id, opts = {}) + result = { success: false, errors: [] } + + is_notify_type = + ReviewableScore.types.slice(:notify_user, :notify_moderators).values.include?(flag_type_id) + is_dm = chat_message.chat_channel.direct_message_channel? + + raise Discourse::InvalidParameters.new(:flag_type) if is_dm && is_notify_type + + guardian.ensure_can_flag_chat_message!(chat_message) + guardian.ensure_can_flag_message_as!(chat_message, flag_type_id, opts) + + existing_reviewable = Reviewable.includes(:reviewable_scores).find_by(target: chat_message) + + if !can_flag_again?(existing_reviewable, chat_message, guardian.user, flag_type_id) + result[:errors] << I18n.t("chat.reviewables.message_already_handled") + return result + end + + payload = { message_cooked: chat_message.cooked } + + if opts[:message].present? && !is_dm && is_notify_type + creator = companion_pm_creator(chat_message, guardian.user, flag_type_id, opts) + post = creator.create + + if creator.errors.present? + creator.errors.full_messages.each { |msg| result[:errors] << msg } + return result + end + elsif is_dm + transcript = find_or_create_transcript(chat_message, guardian.user, existing_reviewable) + payload[:transcript_topic_id] = transcript.topic_id if transcript + end + + queued_for_review = !!ActiveRecord::Type::Boolean.new.deserialize(opts[:queue_for_review]) + + reviewable = + ReviewableChatMessage.needs_review!( + created_by: guardian.user, + target: chat_message, + reviewable_by_moderator: true, + potential_spam: flag_type_id == ReviewableScore.types[:spam], + payload: payload, + ) + reviewable.update(target_created_by: chat_message.user) + score = + reviewable.add_score( + guardian.user, + flag_type_id, + meta_topic_id: post&.topic_id, + take_action: opts[:take_action], + reason: queued_for_review ? "chat_message_queued_by_staff" : nil, + force_review: queued_for_review, + ) + + if opts[:take_action] + reviewable.perform(guardian.user, :agree_and_delete) + ChatPublisher.publish_delete!(chat_message.chat_channel, chat_message) + else + enforce_auto_silence_threshold(reviewable) + ChatPublisher.publish_flag!(chat_message, guardian.user, reviewable, score) + end + + result.tap do |r| + r[:success] = true + r[:reviewable] = reviewable + end + end + + private + + def enforce_auto_silence_threshold(reviewable) + auto_silence_duration = SiteSetting.chat_auto_silence_from_flags_duration + return if auto_silence_duration.zero? + return if reviewable.score <= ReviewableChatMessage.score_to_silence_user + + user = reviewable.target_created_by + return unless user + return if user.silenced? + + UserSilencer.silence( + user, + Discourse.system_user, + silenced_till: auto_silence_duration.minutes.from_now, + reason: I18n.t("chat.errors.auto_silence_from_flags"), + ) + end + + def companion_pm_creator(chat_message, flagger, flag_type_id, opts) + notifying_user = flag_type_id == ReviewableScore.types[:notify_user] + + i18n_key = notifying_user ? "notify_user" : "notify_moderators" + + title = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_body", + message: opts[:message], + link: chat_message.full_url, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + } + + if notifying_user + create_args[:subtype] = TopicSubtype.notify_user + create_args[:target_usernames] = chat_message.user.username + + create_args[:is_warning] = opts[:is_warning] if flagger.staff? + else + create_args[:subtype] = TopicSubtype.notify_moderators + create_args[:target_group_names] = [Group[:moderators].name] + end + + PostCreator.new(flagger, create_args) + end + + def find_or_create_transcript(chat_message, flagger, existing_reviewable) + previous_message_ids = + ChatMessage + .where(chat_channel: chat_message.chat_channel) + .where("id < ?", chat_message.id) + .order("created_at DESC") + .limit(10) + .pluck(:id) + .reverse + + return if previous_message_ids.empty? + + service = + ChatTranscriptService.new( + chat_message.chat_channel, + Discourse.system_user, + messages_or_ids: previous_message_ids, + ) + + title = + I18n.t( + "chat.reviewables.direct_messages.transcript_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "chat.reviewables.direct_messages.transcript_body", + transcript: service.generate_markdown, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + subtype: TopicSubtype.notify_moderators, + target_group_names: [Group[:moderators].name], + } + + PostCreator.new(Discourse.system_user, create_args).create + end + + def can_flag_again?(reviewable, message, flagger, flag_type_id) + return true if reviewable.blank? + + flagger_has_pending_flags = + reviewable.reviewable_scores.any? { |rs| rs.user == flagger && rs.pending? } + + if !flagger_has_pending_flags && flag_type_id == ReviewableScore.types[:notify_moderators] + return true + end + + flag_used = + reviewable.reviewable_scores.any? do |rs| + rs.reviewable_score_type == flag_type_id && rs.pending? + end + handled_recently = + !( + reviewable.pending? || + reviewable.updated_at < SiteSetting.cooldown_hours_until_reflag.to_i.hours.ago + ) + + latest_revision = message.revisions.last + edited_since_last_review = latest_revision && latest_revision.updated_at > reviewable.updated_at + + !flag_used && !flagger_has_pending_flags && (!handled_recently || edited_since_last_review) + end +end diff --git a/plugins/chat/lib/chat_seeder.rb b/plugins/chat/lib/chat_seeder.rb new file mode 100644 index 00000000000..79d8dc23bda --- /dev/null +++ b/plugins/chat/lib/chat_seeder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ChatSeeder + def execute(args = {}) + return if !SiteSetting.needs_chat_seeded + + begin + create_category_channel_from(SiteSetting.staff_category_id) + create_category_channel_from(SiteSetting.general_category_id) + rescue => error + Rails.logger.warn("Error seeding chat category - #{error.inspect}") + ensure + SiteSetting.needs_chat_seeded = false + end + end + + def create_category_channel_from(category_id) + category = Category.find_by(id: category_id) + return if category.nil? + + chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name) + category.custom_fields[Chat::HAS_CHAT_ENABLED] = true + category.save! + + Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + chat_channel + end +end diff --git a/plugins/chat/lib/chat_statistics.rb b/plugins/chat/lib/chat_statistics.rb new file mode 100644 index 00000000000..ab79fcf1110 --- /dev/null +++ b/plugins/chat/lib/chat_statistics.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Chat::Statistics + def self.about_messages + { + :last_day => ChatMessage.where("created_at > ?", 1.days.ago).count, + "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).count, + "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).count, + :previous_30_days => + ChatMessage.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, + :count => ChatMessage.count, + } + end + + def self.about_channels + { + :last_day => ChatChannel.where(status: :open).where("created_at > ?", 1.days.ago).count, + "7_days" => ChatChannel.where(status: :open).where("created_at > ?", 7.days.ago).count, + "30_days" => ChatChannel.where(status: :open).where("created_at > ?", 30.days.ago).count, + :previous_30_days => + ChatChannel + .where(status: :open) + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .count, + :count => ChatChannel.where(status: :open).count, + } + end + + def self.about_users + { + :last_day => ChatMessage.where("created_at > ?", 1.days.ago).distinct.count(:user_id), + "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).distinct.count(:user_id), + "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).distinct.count(:user_id), + :previous_30_days => + ChatMessage + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .distinct + .count(:user_id), + :count => ChatMessage.distinct.count(:user_id), + } + end + + def self.monthly + start_of_month = Time.zone.now.beginning_of_month + { + messages: ChatMessage.where("created_at > ?", start_of_month).count, + channels: ChatChannel.where(status: :open).where("created_at > ?", start_of_month).count, + users: ChatMessage.where("created_at > ?", start_of_month).distinct.count(:user_id), + } + end +end diff --git a/plugins/chat/lib/chat_transcript_service.rb b/plugins/chat/lib/chat_transcript_service.rb new file mode 100644 index 00000000000..6326494cdde --- /dev/null +++ b/plugins/chat/lib/chat_transcript_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +## +# Used to generate BBCode [chat] tags for the message IDs provided. +# +# If there is > 1 message then the channel name will be shown at +# the top of the first message, and subsequent messages will have +# the chained attribute, which will affect how they are displayed +# in the UI. +# +# Subsequent messages from the same user will be put into the same +# tag. Each new user in the chain of messages will have a new [chat] +# tag created. +# +# A single message will have the channel name displayed to the right +# of the username and datetime of the message. +class ChatTranscriptService + CHAINED_ATTR = "chained=\"true\"" + MULTIQUOTE_ATTR = "multiQuote=\"true\"" + NO_LINK_ATTR = "noLink=\"true\"" + + class ChatTranscriptBBCode + attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions + + def initialize( + channel: nil, + acting_user: nil, + multiquote: false, + chained: false, + no_link: false, + include_reactions: false + ) + @channel = channel + @acting_user = acting_user + @multiquote = multiquote + @chained = chained + @no_link = no_link + @include_reactions = include_reactions + @message_data = [] + end + + def add(message:, reactions: nil) + @message_data << { message: message, reactions: reactions } + end + + def render + attrs = [quote_attr(@message_data.first[:message])] + + if channel + attrs << channel_attr + attrs << channel_id_attr + end + + attrs << MULTIQUOTE_ATTR if multiquote + attrs << CHAINED_ATTR if chained + attrs << NO_LINK_ATTR if no_link + attrs << reactions_attr if include_reactions + + <<~MARKDOWN + [chat #{attrs.compact.join(" ")}] + #{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")} + [/chat] + MARKDOWN + end + + private + + def reactions_attr + reaction_data = + @message_data.reduce([]) do |array, msg_data| + if msg_data[:reactions].any? + array << msg_data[:reactions].map { |react| "#{react.emoji}:#{react.usernames}" } + end + array + end + return if reaction_data.empty? + "reactions=\"#{reaction_data.join(";")}\"" + end + + def quote_attr(message) + "quote=\"#{message.user.username};#{message.id};#{message.created_at.iso8601}\"" + end + + def channel_attr + "channel=\"#{channel.title(@acting_user)}\"" + end + + def channel_id_attr + "channelId=\"#{channel.id}\"" + end + end + + def initialize(channel, acting_user, messages_or_ids: [], opts: {}) + @channel = channel + @acting_user = acting_user + + if messages_or_ids.all? { |m| m.is_a?(Numeric) } + @message_ids = messages_or_ids + else + @messages = messages_or_ids + end + @opts = opts + end + + def generate_markdown + previous_message = nil + rendered_markdown = [] + all_messages_same_user = messages.count(:user_id) == 1 + open_bbcode_tag = + ChatTranscriptBBCode.new( + channel: @channel, + acting_user: @acting_user, + multiquote: messages.length > 1, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + + messages.each.with_index do |message, idx| + if previous_message.present? && previous_message.user_id != message.user_id + rendered_markdown << open_bbcode_tag.render + + open_bbcode_tag = + ChatTranscriptBBCode.new( + acting_user: @acting_user, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + end + + if @opts[:include_reactions] + open_bbcode_tag.add(message: message, reactions: reactions_for_message(message)) + else + open_bbcode_tag.add(message: message) + end + previous_message = message + end + + # tie off the last open bbcode + render + rendered_markdown << open_bbcode_tag.render + rendered_markdown.join("\n") + end + + private + + def messages + @messages ||= + ChatMessage + .includes(:user, chat_uploads: :upload) + .where(id: @message_ids, chat_channel_id: @channel.id) + .order(:created_at) + end + + ## + # Queries reactions and returns them in this format + # + # emoji | usernames | chat_message_id + # ---------------------------------------- + # +1 | foo,bar,baz | 102 + # heart | foo | 102 + # sob | bar,baz | 103 + def reactions + @reactions ||= DB.query(<<~SQL, @messages.map(&:id)) + SELECT emoji, STRING_AGG(DISTINCT users.username, ',') AS usernames, chat_message_id + FROM chat_message_reactions + INNER JOIN users on users.id = chat_message_reactions.user_id + WHERE chat_message_id IN (?) + GROUP BY emoji, chat_message_id + ORDER BY chat_message_id, emoji + SQL + end + + def reactions_for_message(message) + reactions.select { |react| react.chat_message_id == message.id } + end +end diff --git a/plugins/chat/lib/direct_message_channel_creator.rb b/plugins/chat/lib/direct_message_channel_creator.rb new file mode 100644 index 00000000000..9c0f93b69f1 --- /dev/null +++ b/plugins/chat/lib/direct_message_channel_creator.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Chat::DirectMessageChannelCreator + class NotAllowed < StandardError + end + + def self.create!(acting_user:, target_users:) + Guardian.new(acting_user).ensure_can_create_direct_message! + target_users.uniq! + direct_messages_channel = DirectMessageChannel.for_user_ids(target_users.map(&:id)) + if direct_messages_channel + chat_channel = ChatChannel.find_by!(chatable: direct_messages_channel) + else + ensure_actor_can_communicate!(acting_user, target_users) + direct_messages_channel = DirectMessageChannel.create!(user_ids: target_users.map(&:id)) + chat_channel = direct_messages_channel.create_chat_channel! + end + + update_memberships(acting_user, target_users, chat_channel.id) + ChatPublisher.publish_new_channel(chat_channel, target_users) + + chat_channel + end + + private + + def self.update_memberships(acting_user, target_users, chat_channel_id) + sql_params = { + acting_user_id: acting_user.id, + user_ids: target_users.map(&:id), + chat_channel_id: chat_channel_id, + always_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + } + + DB.exec(<<~SQL, sql_params) + INSERT INTO user_chat_channel_memberships( + user_id, + chat_channel_id, + muted, + following, + desktop_notification_level, + mobile_notification_level, + created_at, + updated_at + ) + VALUES( + unnest(array[:user_ids]), + :chat_channel_id, + false, + false, + :always_notification_level, + :always_notification_level, + NOW(), + NOW() + ) + ON CONFLICT (user_id, chat_channel_id) DO NOTHING; + + UPDATE user_chat_channel_memberships + SET following = true + WHERE user_id = :acting_user_id AND chat_channel_id = :chat_channel_id; + SQL + end + + def self.ensure_actor_can_communicate!(acting_user, target_users) + # We never want to prevent the actor from communicating with themself. + target_users = target_users.reject { |user| user.id == acting_user.id } + + screener = + UserCommScreener.new(acting_user: acting_user, target_user_ids: target_users.map(&:id)) + + # People blocking the actor. + screener.preventing_actor_communication.each do |user_id| + raise NotAllowed.new( + I18n.t( + "chat.errors.not_accepting_dms", + username: target_users.find { |user| user.id == user_id }.username, + ), + ) + end + + # The actor cannot start DMs with people if they are not allowing anyone + # to start DMs with them, that's no fair! + if screener.actor_disallowing_all_pms? + raise NotAllowed.new(I18n.t("chat.errors.actor_disallowed_dms")) + end + + # People the actor is blocking. + target_users.each do |target_user| + if screener.actor_disallowing_pms?(target_user.id) + raise NotAllowed.new( + I18n.t( + "chat.errors.actor_preventing_target_user_from_dm", + username: target_user.username, + ), + ) + end + + if screener.actor_ignoring?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_ignoring_target_user", username: target_user.username), + ) + end + + if screener.actor_muting?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_muting_target_user", username: target_user.username), + ) + end + end + end +end diff --git a/plugins/chat/lib/discourse_dev/direct_channel.rb b/plugins/chat/lib/discourse_dev/direct_channel.rb new file mode 100644 index 00000000000..87d57066364 --- /dev/null +++ b/plugins/chat/lib/discourse_dev/direct_channel.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class DirectChannel < Record + def initialize + super(::DirectMessageChannel, 5) + end + + def data + if Faker::Boolean.boolean(true_ratio: 0.5) + admin_username = + begin + DiscourseDev::Config.new.config[:admin][:username] + rescue StandardError + nil + end + admin_user = ::User.find_by(username: admin_username) if admin_username + end + + [User.new.create!, admin_user || User.new.create!] + end + + def create! + users = data + Chat::DirectMessageChannelCreator.create!(acting_user: users[0], target_users: users) + end + end +end diff --git a/plugins/chat/lib/discourse_dev/message.rb b/plugins/chat/lib/discourse_dev/message.rb new file mode 100644 index 00000000000..a82f5cd8933 --- /dev/null +++ b/plugins/chat/lib/discourse_dev/message.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class Message < Record + def initialize + super(::ChatMessage, 200) + end + + def data + if Faker::Boolean.boolean(true_ratio: 0.5) + channel = ::ChatChannel.where(chatable_type: "DirectMessageChannel").order("RANDOM()").first + channel.user_chat_channel_memberships.update_all(following: true) + user = channel.chatable.users.order("RANDOM()").first + else + membership = ::UserChatChannelMembership.order("RANDOM()").first + channel = membership.chat_channel + user = membership.user + end + + { user: user, content: Faker::Lorem.paragraph, chat_channel: channel } + end + + def create! + Chat::ChatMessageCreator.create(data) + end + end +end diff --git a/plugins/chat/lib/discourse_dev/public_channel.rb b/plugins/chat/lib/discourse_dev/public_channel.rb new file mode 100644 index 00000000000..cb9c672caa9 --- /dev/null +++ b/plugins/chat/lib/discourse_dev/public_channel.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "discourse_dev/record" +require "faker" + +module DiscourseDev + class PublicChannel < Record + def initialize + super(::CategoryChannel, 5) + end + + def data + chatable = Category.random + + { + chatable: chatable, + description: Faker::Lorem.paragraph, + user_count: 1, + name: Faker::Company.name, + created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), + } + end + + def create! + super do |channel| + Faker::Number + .between(from: 5, to: 10) + .times do + if Faker::Boolean.boolean(true_ratio: 0.5) + admin_username = + begin + DiscourseDev::Config.new.config[:admin][:username] + rescue StandardError + nil + end + admin_user = ::User.find_by(username: admin_username) if admin_username + end + + Chat::ChatChannelMembershipManager.new(channel).follow(admin_user || User.new.create!) + end + end + end + end +end diff --git a/plugins/chat/lib/duplicate_message_validator.rb b/plugins/chat/lib/duplicate_message_validator.rb new file mode 100644 index 00000000000..c66420f9d7d --- /dev/null +++ b/plugins/chat/lib/duplicate_message_validator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Chat::DuplicateMessageValidator + attr_reader :chat_message + + def initialize(chat_message) + @chat_message = chat_message + end + + def validate + return if SiteSetting.chat_duplicate_message_sensitivity.zero? + matrix = + Chat::DuplicateMessageValidator.sensitivity_matrix( + SiteSetting.chat_duplicate_message_sensitivity, + ) + + # Check if the length of the message is too short to check for a duplicate message + return if chat_message.message.length < matrix[:min_message_length] + + # Check if there are enough users in the channel to check for a duplicate message + return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count] + + # Check if the same duplicate message has been posted in the last N seconds by any user + if !chat_message + .chat_channel + .chat_messages + .where("created_at > ?", matrix[:min_past_seconds].seconds.ago) + .where(message: chat_message.message) + .exists? + return + end + + chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message")) + end + + def self.sensitivity_matrix(sensitivity) + { + # 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users. + min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i, + # 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars. + min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i, + # 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds. + min_past_seconds: (55.55 * sensitivity + 4.5).to_i, + } + end +end diff --git a/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb b/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb new file mode 100644 index 00000000000..ab4b06a7576 --- /dev/null +++ b/plugins/chat/lib/email_controller_helper/chat_summary_unsubscriber.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EmailControllerHelper + class ChatSummaryUnsubscriber < BaseEmailUnsubscriber + def prepare_unsubscribe_options(controller) + super(controller) + + chat_email_frequencies = + UserOption.chat_email_frequencies.map do |(freq, _)| + [I18n.t("unsubscribe.chat_summary.#{freq}"), freq] + end + + controller.instance_variable_set(:@chat_email_frequencies, chat_email_frequencies) + controller.instance_variable_set( + :@current_chat_email_frequency, + key_owner.user_option.chat_email_frequency, + ) + end + + def unsubscribe(params) + updated = super(params) + + if params[:chat_email_frequency] + key_owner.user_option.update!(chat_email_frequency: params[:chat_email_frequency]) + updated = true + end + + updated + end + end +end diff --git a/plugins/chat/lib/extensions/category_extension.rb b/plugins/chat/lib/extensions/category_extension.rb new file mode 100644 index 00000000000..d5bf9b2ad8e --- /dev/null +++ b/plugins/chat/lib/extensions/category_extension.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat::CategoryExtension + extend ActiveSupport::Concern + + include Chatable + + prepended { has_one :category_channel, as: :chatable } + + def cannot_delete_reason + return I18n.t("category.cannot_delete.has_chat_channels") if category_channel + super + end +end diff --git a/plugins/chat/lib/extensions/user_email_extension.rb b/plugins/chat/lib/extensions/user_email_extension.rb new file mode 100644 index 00000000000..6742dccbe37 --- /dev/null +++ b/plugins/chat/lib/extensions/user_email_extension.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat::UserEmailExtension + def execute(args) + super(args) + + if args[:type] == "chat_summary" && args[:memberships_to_update_data].present? + args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id| + UserChatChannelMembership.find_by(user: args[:user_id], id: membership_id.to_i)&.update( + last_unread_mention_when_emailed_id: max_unread_mention_id.to_i, + ) + end + end + end +end diff --git a/plugins/chat/lib/extensions/user_extension.rb b/plugins/chat/lib/extensions/user_extension.rb new file mode 100644 index 00000000000..b4c041d4d8b --- /dev/null +++ b/plugins/chat/lib/extensions/user_extension.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat::UserExtension + extend ActiveSupport::Concern + + prepended do + has_many :user_chat_channel_memberships, dependent: :destroy + has_many :chat_message_reactions, dependent: :destroy + has_many :chat_mentions + end +end diff --git a/plugins/chat/lib/extensions/user_notifications_extension.rb b/plugins/chat/lib/extensions/user_notifications_extension.rb new file mode 100644 index 00000000000..fab9f70ea10 --- /dev/null +++ b/plugins/chat/lib/extensions/user_notifications_extension.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Chat::UserNotificationsExtension + def chat_summary(user, opts) + guardian = Guardian.new(user) + return unless guardian.can_chat?(user) + + @messages = + ChatMessage + .joins(:user, :chat_channel) + .where.not(user: user) + .where("chat_messages.created_at > ?", 1.week.ago) + .joins("LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id") + .joins( + "INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id", + ) + .where(<<~SQL, user_id: user.id) + uccm.user_id = :user_id AND + (uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (cm.user_id = :user_id AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR + (chat_channels.chatable_type = 'DirectMessageChannel') + ) + SQL + .to_a + + return if @messages.empty? + @grouped_messages = @messages.group_by { |message| message.chat_channel } + @grouped_messages = + @grouped_messages.select { |channel, _| guardian.can_see_chat_channel?(channel) } + return if @grouped_messages.empty? + + @grouped_messages.each do |chat_channel, messages| + @grouped_messages[chat_channel] = messages.sort_by(&:created_at) + end + @user = user + @user_tz = UserOption.user_tzinfo(user.id) + @display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + + build_summary_for(user) + @preferences_path = "#{Discourse.base_url}/my/preferences/chat" + + # TODO(roman): Remove after the 2.9 release + add_unsubscribe_link = UnsubscribeKey.respond_to?(:get_unsubscribe_strategy_for) + + if add_unsubscribe_link + unsubscribe_key = UnsubscribeKey.create_key_for(@user, "chat_summary") + @unsubscribe_link = "#{Discourse.base_url}/email/unsubscribe/#{unsubscribe_key}" + opts[:unsubscribe_url] = @unsubscribe_link + end + + opts = { + from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title), + subject: summary_subject(user, @grouped_messages), + add_unsubscribe_link: add_unsubscribe_link, + } + + build_email(user.email, opts) + end + + def summary_subject(user, grouped_messages) + channels = grouped_messages.keys + grouped_channels = channels.partition { |c| !c.direct_message_channel? } + non_dm_channels = grouped_channels.first + dm_users = grouped_channels.last.flat_map { |c| grouped_messages[c].map(&:user) }.uniq + + total_count_for_subject = non_dm_channels.size + dm_users.size + first_message_from = non_dm_channels.pop + if first_message_from + first_message_title = first_message_from.title(user) + subject_key = "chat_channel" + else + subject_key = "direct_message" + first_message_from = dm_users.pop + first_message_title = first_message_from.username + end + + subject_opts = { + email_prefix: @email_prefix, + count: total_count_for_subject, + message_title: first_message_title, + others: + other_channels_text( + user, + total_count_for_subject, + first_message_from, + non_dm_channels, + dm_users, + ), + } + + I18n.t(with_subject_prefix(subject_key), **subject_opts) + end + + def with_subject_prefix(key) + "user_notifications.chat_summary.subject.#{key}" + end + + def other_channels_text( + user, + total_count, + first_message_from, + other_non_dm_channels, + other_dm_users + ) + return if total_count <= 1 + return I18n.t(with_subject_prefix("others"), count: total_count - 1) if total_count > 2 + + if other_non_dm_channels.empty? + second_message_from = other_dm_users.first + second_message_title = second_message_from.username + else + second_message_from = other_non_dm_channels.first + second_message_title = second_message_from.title(user) + end + + return second_message_title if first_message_from.class == second_message_from.class + + I18n.t(with_subject_prefix("other_direct_message"), message_title: second_message_title) + end +end diff --git a/plugins/chat/lib/extensions/user_option_extension.rb b/plugins/chat/lib/extensions/user_option_extension.rb new file mode 100644 index 00000000000..ae2993a216f --- /dev/null +++ b/plugins/chat/lib/extensions/user_option_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat::UserOptionExtension + # TODO: remove last_emailed_for_chat and chat_isolated in 2023 + def self.prepended(base) + if base.ignored_columns + base.ignored_columns = base.ignored_columns + %i[last_emailed_for_chat chat_isolated] + else + base.ignored_columns = %i[last_emailed_for_chat chat_isolated] + end + + def base.chat_email_frequencies + @chat_email_frequencies ||= { never: 0, when_away: 1 } + end + + base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" + end +end diff --git a/plugins/chat/lib/guardian_extensions.rb b/plugins/chat/lib/guardian_extensions.rb new file mode 100644 index 00000000000..c251da81b33 --- /dev/null +++ b/plugins/chat/lib/guardian_extensions.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Chat::GuardianExtensions + def can_moderate_chat?(chatable) + case chatable.class.name + when "Category" + is_staff? || is_category_group_moderator?(chatable) + else + is_staff? + end + end + + def can_chat?(user) + return false unless user + + user.staff? || user.in_any_groups?(Chat.allowed_group_ids) + end + + def can_create_chat_message? + !SpamRule::AutoSilence.prevent_posting?(@user) + end + + def can_create_direct_message? + is_staff? || @user.in_any_groups?(SiteSetting.direct_message_enabled_groups_map) + end + + def hidden_tag_names + @hidden_tag_names ||= DiscourseTagging.hidden_tag_names(self) + end + + def can_create_chat_channel? + is_staff? + end + + def can_delete_chat_channel? + is_staff? + end + + # Channel status intentionally has no bearing on whether the channel + # name and description can be edited. + def can_edit_chat_channel? + is_staff? + end + + def can_move_chat_messages?(channel) + can_moderate_chat?(channel.chatable) + end + + def can_create_channel_message?(chat_channel) + valid_statuses = is_staff? ? %w[open closed] : ["open"] + valid_statuses.include?(chat_channel.status) + end + + # This is intentionally identical to can_create_channel_message, we + # may want to have different conditions here in future. + def can_modify_channel_message?(chat_channel) + return chat_channel.open? || chat_channel.closed? if is_staff? + chat_channel.open? + end + + def can_change_channel_status?(chat_channel, target_status) + return false if chat_channel.status.to_sym == target_status.to_sym + return false if !is_staff? + + case target_status + when :closed + chat_channel.open? + when :open + chat_channel.closed? + when :archived + chat_channel.read_only? + when :read_only + chat_channel.closed? || chat_channel.open? + else + false + end + end + + def can_rebake_chat_message?(message) + return false if !can_modify_channel_message?(message.chat_channel) + is_staff? || @user.has_trust_level?(TrustLevel[4]) + end + + def can_see_chat_channel?(chat_channel) + return false unless chat_channel.chatable + + if chat_channel.direct_message_channel? + chat_channel.chatable.user_can_access?(@user) + elsif chat_channel.category_channel? + can_see_category?(chat_channel.chatable) + else + true + end + end + + def can_flag_chat_messages? + return false if @user.silenced? + + @user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map) + end + + def can_flag_in_chat_channel?(chat_channel) + return false if !can_modify_channel_message?(chat_channel) + + can_see_chat_channel?(chat_channel) + end + + def can_flag_chat_message?(chat_message) + return false if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user + return false if chat_message.user.staff? && !SiteSetting.allow_flagging_staff + return false if chat_message.user_id == @user.id + + can_flag_chat_messages? && can_flag_in_chat_channel?(chat_message.chat_channel) + end + + def can_flag_message_as?(chat_message, flag_type_id, opts) + return false if !is_staff? && (opts[:take_action] || opts[:queue_for_review]) + + if flag_type_id == ReviewableScore.types[:notify_user] + is_warning = ActiveRecord::Type::Boolean.new.deserialize(opts[:is_warning]) + + return false if is_warning && !is_staff? + end + + true + end + + def can_delete_chat?(message, chatable) + return false if @user.silenced? + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + can_delete_own_chats?(chatable) + else + can_delete_other_chats?(chatable) + end + end + + def can_delete_own_chats?(chatable) + return false if (SiteSetting.max_post_deletions_per_day < 1) + return true if can_moderate_chat?(chatable) + + true + end + + def can_delete_other_chats?(chatable) + return true if can_moderate_chat?(chatable) + + false + end + + def can_restore_chat?(message, chatable) + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + case chatable.class.name + when "Category" + return can_see_category?(chatable) + when "DirectMessageChannel" + return true + end + end + + can_delete_other_chats?(chatable) + end + + def can_restore_other_chats?(chatable) + can_moderate_chat?(chatable) + end + + def can_edit_chat?(message) + message.user_id == @user.id && !@user.silenced? + end + + def can_react? + can_create_chat_message? + end + + def can_delete_category?(category) + super && !category.category_channel + end +end diff --git a/plugins/chat/lib/message_mover.rb b/plugins/chat/lib/message_mover.rb new file mode 100644 index 00000000000..8e9a80e87bc --- /dev/null +++ b/plugins/chat/lib/message_mover.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +## +# Used to move chat messages from a chat channel to some other +# location. +# +# Channel -> Channel: +# ------------------- +# +# Messages are sometimes misplaced and must be moved to another channel. For +# now we only support moving messages between public channels, handling the +# permissions and membership around moving things in and out of DMs is a little +# much for V1. +# +# The original messages will be deleted, and then similar to PostMover in core, +# all of the references associated to a chat message (e.g. reactions, bookmarks, +# notifications, revisions, mentions, uploads) will be updated to the new +# message IDs via a moved_chat_messages temporary table. +class Chat::MessageMover + class NoMessagesFound < StandardError + end + class InvalidChannel < StandardError + end + + def initialize(acting_user:, source_channel:, message_ids:) + @source_channel = source_channel + @acting_user = acting_user + @source_message_ids = message_ids + @source_messages = find_messages(@source_message_ids, source_channel) + @ordered_source_message_ids = @source_messages.map(&:id) + end + + def move_to_channel(destination_channel) + if !@source_channel.public_channel? || !destination_channel.public_channel? + raise InvalidChannel.new(I18n.t("chat.errors.message_move_invalid_channel")) + end + + if @ordered_source_message_ids.empty? + raise NoMessagesFound.new(I18n.t("chat.errors.message_move_no_messages_found")) + end + + moved_messages = nil + + ChatMessage.transaction do + create_temp_table + moved_messages = + find_messages( + create_destination_messages_in_channel(destination_channel), + destination_channel, + ) + bulk_insert_movement_metadata + update_references + delete_source_messages + end + + add_moved_placeholder(destination_channel, moved_messages.first) + moved_messages + end + + private + + def find_messages(message_ids, channel) + ChatMessage.where(id: message_ids, chat_channel_id: channel.id).order("created_at ASC, id ASC") + end + + def create_temp_table + DB.exec("DROP TABLE IF EXISTS moved_chat_messages") if Rails.env.test? + + DB.exec <<~SQL + CREATE TEMPORARY TABLE moved_chat_messages ( + old_chat_message_id INTEGER, + new_chat_message_id INTEGER + ) ON COMMIT DROP; + + CREATE INDEX moved_chat_messages_old_chat_message_id ON moved_chat_messages(old_chat_message_id); + SQL + end + + def bulk_insert_movement_metadata + values_sql = @movement_metadata.map { |mm| "(#{mm[:old_id]}, #{mm[:new_id]})" }.join(",\n") + DB.exec( + "INSERT INTO moved_chat_messages(old_chat_message_id, new_chat_message_id) VALUES #{values_sql}", + ) + end + + ## + # We purposefully omit in_reply_to_id when creating the messages in the + # new channel, because it could be pointing to a message that has not + # been moved. + def create_destination_messages_in_channel(destination_channel) + query_args = { + message_ids: @ordered_source_message_ids, + destination_channel_id: destination_channel.id, + } + moved_message_ids = DB.query_single(<<~SQL, query_args) + INSERT INTO chat_messages(chat_channel_id, user_id, message, cooked, cooked_version, created_at, updated_at) + SELECT :destination_channel_id, + user_id, + message, + cooked, + cooked_version, + CLOCK_TIMESTAMP(), + CLOCK_TIMESTAMP() + FROM chat_messages + WHERE id IN (:message_ids) + RETURNING id + SQL + + @movement_metadata = + moved_message_ids.map.with_index do |chat_message_id, idx| + { old_id: @ordered_source_message_ids[idx], new_id: chat_message_id } + end + moved_message_ids + end + + def update_references + DB.exec(<<~SQL) + UPDATE chat_message_reactions cmr + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cmr.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_uploads cu + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cu.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_mentions cment + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cment.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_message_revisions crev + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE crev.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_webhook_events cweb + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cweb.chat_message_id = mm.old_chat_message_id + SQL + end + + def delete_source_messages + @source_messages.update_all(deleted_at: Time.zone.now, deleted_by_id: @acting_user.id) + ChatPublisher.publish_bulk_delete!(@source_channel, @source_message_ids) + end + + def add_moved_placeholder(destination_channel, first_moved_message) + Chat::ChatMessageCreator.create( + chat_channel: @source_channel, + user: Discourse.system_user, + content: + I18n.t( + "chat.channel.messages_moved", + count: @source_message_ids.length, + acting_username: @acting_user.username, + channel_name: destination_channel.title(@acting_user), + first_moved_message_url: first_moved_message.url, + ), + ) + end +end diff --git a/plugins/chat/lib/onebox/templates/discourse_chat.mustache b/plugins/chat/lib/onebox/templates/discourse_chat.mustache new file mode 100644 index 00000000000..b0bcc8ef5e8 --- /dev/null +++ b/plugins/chat/lib/onebox/templates/discourse_chat.mustache @@ -0,0 +1,58 @@ +{{^cooked}} + +{{/cooked}} + +{{#cooked}} + +{{/cooked}} diff --git a/plugins/chat/lib/post_notification_handler.rb b/plugins/chat/lib/post_notification_handler.rb new file mode 100644 index 00000000000..beefe24ab73 --- /dev/null +++ b/plugins/chat/lib/post_notification_handler.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +## +# Handles :post_alerter_after_save_post events from +# core. Used for notifying users that their chat message +# has been quoted in a post. +class Chat::PostNotificationHandler + attr_reader :post + + def initialize(post, notified_users) + @post = post + @notified_users = notified_users + end + + def handle + return false if post.post_type == Post.types[:whisper] + return false if post.topic.blank? + return false if post.topic.private_message? + + quoted_users = extract_quoted_users(post) + if @notified_users.present? + quoted_users = quoted_users.where("users.id NOT IN (?)", @notified_users) + end + + opts = { user_id: post.user.id, display_username: post.user.username } + quoted_users.each do |user| + # PostAlerter.create_notification handles many edge cases, such as + # muting, ignoring, double notifications etc. + PostAlerter.new.create_notification(user, Notification.types[:chat_quoted], post, opts) + end + end + + private + + def extract_quoted_users(post) + usernames = + post.raw.scan(/\[chat quote=\"([^;]+);.+\"\]/).uniq.map { |q| q.first.strip.downcase } + User.where.not(id: post.user_id).where(username_lower: usernames) + end +end diff --git a/plugins/chat/lib/secure_uploads_compatibility.rb b/plugins/chat/lib/secure_uploads_compatibility.rb new file mode 100644 index 00000000000..6fd898f10bc --- /dev/null +++ b/plugins/chat/lib/secure_uploads_compatibility.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Chat::SecureUploadsCompatibility + ## + # At this point in time, secure uploads is not compatible with chat, + # so if it is enabled then chat uploads must be disabled to avoid undesirable + # behaviour. + # + # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep + # it enabled, but this is strongly advised against. + def self.update_settings + if SiteSetting.secure_uploads && SiteSetting.chat_allow_uploads && + !GlobalSetting.allow_unsecure_chat_uploads + SiteSetting.chat_allow_uploads = false + StaffActionLogger.new(Discourse.system_user).log_site_setting_change( + "chat_allow_uploads", + true, + false, + context: "Disabled because secure_uploads is enabled", + ) + end + end +end diff --git a/plugins/chat/lib/slack_compatibility.rb b/plugins/chat/lib/slack_compatibility.rb new file mode 100644 index 00000000000..106af32caf3 --- /dev/null +++ b/plugins/chat/lib/slack_compatibility.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +## +# Processes slack-formatted text messages, as Mattermost does with +# Slack incoming webhook interoperability, for example links in the +# format and , and mentions. +# +# See https://api.slack.com/reference/surfaces/formatting for all of +# the different formatting slack supports with mrkdwn which is mostly +# identical to Markdown. +# +# Mattermost docs for translating the slack format: +# +# https://docs.mattermost.com/developer/webhooks-incoming.html?highlight=translate%20slack%20data%20format%20mattermost#translate-slack-s-data-format-to-mattermost +# +# We may want to process attachments and blocks from slack in future, and +# convert user IDs into user mentions. +class Chat::SlackCompatibility + MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze + + class << self + def process_text(text) + text = text.gsub("", "@here") + text = text.gsub("", "@all") + + text.scan(MRKDWN_LINK_REGEX) do |match| + match = match.first + + if match.include?("|") + link, title = match.split("|")[0..1] + else + link = match + end + + title = title&.gsub(/<|>/, "") + link = link&.gsub(/<|>/, "") + + if title + text = text.gsub(match, "[#{title}](#{link})") + else + text = text.gsub(match, "#{link}") + end + end + + text + end + + # TODO: This is quite hacky and is only here to support a single + # attachment for our OpsGenie integration. In future we would + # want to iterate through this attachments array and extract + # things properly. + # + # See https://api.slack.com/reference/messaging/attachments for + # more details on what fields are here. + def process_legacy_attachments(attachments) + text = CGI.unescape(attachments[0][:fallback]) + process_text(text) + end + end +end diff --git a/plugins/chat/lib/tasks/chat.rake b/plugins/chat/lib/tasks/chat.rake new file mode 100644 index 00000000000..a53e1b319cc --- /dev/null +++ b/plugins/chat/lib/tasks/chat.rake @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +if Discourse.allow_dev_populate? + chat_task = Rake::Task["dev:populate"] + chat_task.enhance do + SiteSetting.chat_enabled = true + DiscourseDev::PublicChannel.populate! + DiscourseDev::DirectChannel.populate! + DiscourseDev::Message.populate! + end + + desc "Generates sample content for chat" + task "chat:populate" => ["db:load_config"] do |_, args| + DiscourseDev::PublicChannel.new.populate!(ignore_current_count: true) + DiscourseDev::DirectChannel.new.populate!(ignore_current_count: true) + DiscourseDev::Message.new.populate!(ignore_current_count: true) + end + + desc "Generates sample messages in channels" + task "chat:message:populate" => ["db:load_config"] do |_, args| + DiscourseDev::Message.new.populate!(ignore_current_count: true) + end +end diff --git a/plugins/chat/lib/tasks/chat_message.rake b/plugins/chat/lib/tasks/chat_message.rake new file mode 100644 index 00000000000..603722e4ba4 --- /dev/null +++ b/plugins/chat/lib/tasks/chat_message.rake @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +task "chat_messages:rebake_uncooked_chat_messages" => :environment do + # rebaking uncooked chat_messages can very quickly saturate sidekiq + # this provides an insurance policy so you can safely run and stop + # this rake task without worrying about your sidekiq imploding + Jobs.run_immediately! + + ENV["RAILS_DB"] ? rebake_uncooked_chat_messages : rebake_uncooked_chat_messages_all_sites +end + +def rebake_uncooked_chat_messages_all_sites + RailsMultisite::ConnectionManagement.each_connection { |db| rebake_uncooked_chat_messages } +end + +def rebake_uncooked_chat_messages + puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}" + uncooked = ChatMessage.uncooked + + rebaked = 0 + total = uncooked.count + + ids = uncooked.pluck(:id) + # work randomly so you can run this job from lots of consoles if needed + ids.shuffle! + + ids.each do |id| + # may have been cooked in interim + chat_message = uncooked.where(id: id).first + + rebake_chat_message(chat_message) if chat_message + + print_status(rebaked += 1, total) + end + + puts "", "#{rebaked} chat messages done!", "" +end + +def rebake_chat_message(chat_message, opts = {}) + opts[:priority] = :ultra_low if !opts[:priority] + chat_message.rebake!(**opts) +rescue => e + puts "", + "Failed to rebake chat message (chat_message_id: #{chat_message.id})", + e, + e.backtrace.join("\n") +end + +task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environment do |t, args| + user_for_membership = args[:user_for_membership] + + # do not want this running in production! + return if !Rails.env.development? + + require "fabrication" + Dir[Rails.root.join("spec/fabricators/*.rb")].each { |f| require f } + + messages = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Cras sit **amet** metus eget nisl accumsan ullamcorper.", + "Vestibulum commodo justo _quis_ fringilla fringilla.", + "Etiam malesuada erat eget aliquam interdum.", + "Praesent mattis lacus nec ~~orci~~ [spoiler]semper[/spoiler], et fermentum augue tincidunt.", + "Duis vel tortor suscipit justo fringilla faucibus id tempus purus.", + "Phasellus *tempus erat* sit amet pharetra facilisis.", + "Fusce egestas urna ut nisi ornare, ut malesuada est fermentum.", + "Aenean ornare arcu vitae pulvinar dictum.", + "Nam at turpis eu magna sollicitudin fringilla sed sed diam.", + "Proin non [enim](https://discourse.org/team) nec mauris efficitur convallis.", + "Nullam cursus lacus non libero vulputate ornare.", + "In eleifend ante ut ullamcorper ultrices.", + "In placerat diam sit amet nibh feugiat, in posuere metus feugiat.", + "Nullam porttitor leo a leo `cursus`, id hendrerit dui ultrices.", + "Pellentesque ut @#{user_for_membership} ut ex pulvinar pharetra sit amet ac leo.", + "Vestibulum sit amet enim et lectus tincidunt rhoncus hendrerit in enim.", + <<~MSG, + some bigger message + + ```ruby + beep = \"wow\" + puts beep + ``` + MSG + ] + + topic = nil + chat_channel = nil + + Topic.transaction do + topic = + Fabricate( + :topic, + user: make_test_user, + title: "Testing topic for chat archiving #{SecureRandom.hex(4)}", + ) + Fabricate( + :post, + topic: topic, + user: topic.user, + raw: "This is some cool first post for archive stuff", + ) + chat_channel = + ChatChannel.create( + chatable: topic, + chatable_type: "Topic", + name: "testing channel for archiving #{SecureRandom.hex(4)}", + ) + end + + puts "topic: #{topic.id}, #{topic.title}" + puts "channel: #{chat_channel.id}, #{chat_channel.name}" + + users = [make_test_user, make_test_user, make_test_user] + + ChatChannel.transaction do + start_time = Time.now + + puts "creating 1039 messages for the channel" + 1039.times do + cm = ChatMessage.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) + cm.cook + cm.save! + end + + puts "message creation done" + puts "took #{Time.now - start_time} seconds" + + UserChatChannelMembership.create( + chat_channel: chat_channel, + last_read_message_id: 0, + user: User.find_by(username: user_for_membership), + following: true, + ) + end + + puts "channel is located at #{chat_channel.url}" +end + +def make_test_user + return if !Rails.env.development? + unique_prefix = "archiveuser#{SecureRandom.hex(4)}" + Fabricate(:user, username: unique_prefix, email: "#{unique_prefix}@testemail.com") +end diff --git a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb new file mode 100644 index 00000000000..bd7bbd4b020 --- /dev/null +++ b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ChatAllowUploadsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + return false if value == "t" && prevent_enabling_chat_uploads? + true + end + + def error_message + if prevent_enabling_chat_uploads? + I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads") + end + end + + def prevent_enabling_chat_uploads? + SiteSetting.secure_uploads && !GlobalSetting.allow_unsecure_chat_uploads + end +end diff --git a/plugins/chat/lib/validators/chat_default_channel_validator.rb b/plugins/chat/lib/validators/chat_default_channel_validator.rb new file mode 100644 index 00000000000..917663fcfea --- /dev/null +++ b/plugins/chat/lib/validators/chat_default_channel_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ChatDefaultChannelValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + !!(value == "" || ChatChannel.find_by(id: value.to_i)&.public_channel?) + end + + def error_message + I18n.t("site_settings.errors.chat_default_channel") + end +end diff --git a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb new file mode 100644 index 00000000000..bcd54905124 --- /dev/null +++ b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DirectMessageEnabledGroupsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + val.present? && val != "" + end + + def error_message + I18n.t("site_settings.errors.direct_message_enabled_groups_invalid") + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb new file mode 100644 index 00000000000..cad907e40c5 --- /dev/null +++ b/plugins/chat/plugin.rb @@ -0,0 +1,729 @@ +# frozen_string_literal: true + +# name: chat +# about: Chat inside Discourse +# version: 0.4 +# authors: Kane York, Mark VanLandingham, Martin Brennan, Joffrey Jaffeux +# url: https://github.com/discourse/discourse/tree/main/plugins/chat +# transpile_js: true + +enabled_site_setting :chat_enabled + +register_asset "stylesheets/mixins/chat-scrollbar.scss" +register_asset "stylesheets/common/core-extensions.scss" +register_asset "stylesheets/common/chat-emoji-picker.scss" +register_asset "stylesheets/common/chat-channel-card.scss" +register_asset "stylesheets/common/dc-filter-input.scss" +register_asset "stylesheets/common/common.scss" +register_asset "stylesheets/common/chat-browse.scss" +register_asset "stylesheets/common/chat-drawer.scss" +register_asset "stylesheets/mobile/chat-index.scss", :mobile +register_asset "stylesheets/common/chat-channel-preview-card.scss" +register_asset "stylesheets/common/chat-channel-info.scss" +register_asset "stylesheets/mobile/chat-channel-info.scss", :mobile +register_asset "stylesheets/common/chat-draft-channel.scss" +register_asset "stylesheets/common/chat-tabs.scss" +register_asset "stylesheets/common/chat-form.scss" +register_asset "stylesheets/common/d-progress-bar.scss" +register_asset "stylesheets/common/incoming-chat-webhooks.scss" +register_asset "stylesheets/mobile/chat-message.scss", :mobile +register_asset "stylesheets/desktop/chat-message.scss", :desktop +register_asset "stylesheets/common/chat-channel-title.scss" +register_asset "stylesheets/desktop/chat-channel-title.scss", :desktop +register_asset "stylesheets/common/full-page-chat-header.scss" +register_asset "stylesheets/common/chat-reply.scss" +register_asset "stylesheets/common/chat-message.scss" +register_asset "stylesheets/common/chat-message-left-gutter.scss" +register_asset "stylesheets/common/chat-message-info.scss" +register_asset "stylesheets/common/chat-composer-inline-button.scss" +register_asset "stylesheets/common/chat-replying-indicator.scss" +register_asset "stylesheets/common/chat-composer.scss" +register_asset "stylesheets/desktop/chat-composer.scss", :desktop +register_asset "stylesheets/mobile/chat-composer.scss", :mobile +register_asset "stylesheets/common/direct-message-creator.scss" +register_asset "stylesheets/common/chat-message-collapser.scss" +register_asset "stylesheets/common/chat-message-images.scss" +register_asset "stylesheets/common/chat-transcript.scss" +register_asset "stylesheets/common/chat-composer-dropdown.scss" +register_asset "stylesheets/common/chat-retention-reminder.scss" +register_asset "stylesheets/common/chat-composer-uploads.scss" +register_asset "stylesheets/desktop/chat-composer-uploads.scss", :desktop +register_asset "stylesheets/common/chat-composer-upload.scss" +register_asset "stylesheets/common/chat-selection-manager.scss" +register_asset "stylesheets/mobile/chat-selection-manager.scss", :mobile +register_asset "stylesheets/common/chat-channel-selector-modal.scss" +register_asset "stylesheets/mobile/mobile.scss", :mobile +register_asset "stylesheets/desktop/desktop.scss", :desktop +register_asset "stylesheets/sidebar-extensions.scss" +register_asset "stylesheets/desktop/sidebar-extensions.scss", :desktop +register_asset "stylesheets/common/chat-message-separator.scss" +register_asset "stylesheets/common/chat-onebox.scss" +register_asset "stylesheets/common/chat-skeleton.scss" +register_asset "stylesheets/colors.scss", :color_definitions +register_asset "stylesheets/common/reviewable-chat-message.scss" + +register_svg_icon "comments" +register_svg_icon "comment-slash" +register_svg_icon "hashtag" +register_svg_icon "lock" + +register_svg_icon "file-audio" +register_svg_icon "file-video" +register_svg_icon "file-image" + +# route: /admin/plugins/chat +add_admin_route "chat.admin.title", "chat" + +# Site setting validators must be loaded before initialize +require_relative "lib/validators/chat_default_channel_validator.rb" +require_relative "lib/validators/chat_allow_uploads_validator.rb" +require_relative "lib/validators/direct_message_enabled_groups_validator.rb" +require_relative "app/core_ext/plugin_instance.rb" + +GlobalSetting.add_default(:allow_unsecure_chat_uploads, false) + +after_initialize do + module ::Chat + PLUGIN_NAME = "chat" + HAS_CHAT_ENABLED = "has_chat_enabled" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Chat + end + + def self.allowed_group_ids + SiteSetting.chat_allowed_groups_map + end + + def self.onebox_template + @onebox_template ||= + begin + path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" + File.read(path) + end + end + end + + register_seedfu_fixtures(Rails.root.join("plugins", "chat", "db", "fixtures")) + + load File.expand_path( + "../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb", + __FILE__, + ) + load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__) + load File.expand_path("../app/controllers/chat_controller.rb", __FILE__) + load File.expand_path("../app/controllers/chat_channels_controller.rb", __FILE__) + load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__) + load File.expand_path("../app/controllers/direct_messages_controller.rb", __FILE__) + load File.expand_path("../app/controllers/incoming_chat_webhooks_controller.rb", __FILE__) + load File.expand_path("../app/models/concerns/chatable.rb", __FILE__) + load File.expand_path("../app/models/deleted_chat_user.rb", __FILE__) + load File.expand_path("../app/models/user_chat_channel_membership.rb", __FILE__) + load File.expand_path("../app/models/chat_channel.rb", __FILE__) + load File.expand_path("../app/models/chat_channel_archive.rb", __FILE__) + load File.expand_path("../app/models/chat_draft.rb", __FILE__) + load File.expand_path("../app/models/chat_message.rb", __FILE__) + load File.expand_path("../app/models/chat_message_reaction.rb", __FILE__) + load File.expand_path("../app/models/chat_message_revision.rb", __FILE__) + load File.expand_path("../app/models/chat_mention.rb", __FILE__) + load File.expand_path("../app/models/chat_upload.rb", __FILE__) + load File.expand_path("../app/models/chat_webhook_event.rb", __FILE__) + load File.expand_path("../app/models/d_m_channel.rb", __FILE__) + load File.expand_path("../app/models/direct_message_channel.rb", __FILE__) + load File.expand_path("../app/models/direct_message_user.rb", __FILE__) + load File.expand_path("../app/models/incoming_chat_webhook.rb", __FILE__) + load File.expand_path("../app/models/reviewable_chat_message.rb", __FILE__) + load File.expand_path("../app/models/chat_view.rb", __FILE__) + load File.expand_path("../app/models/category_channel.rb", __FILE__) + load File.expand_path("../app/serializers/structured_channel_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_webhook_event_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_in_reply_to_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/user_chat_channel_membership_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_message_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__) + load File.expand_path( + "../app/serializers/user_with_custom_fields_and_status_serializer.rb", + __FILE__, + ) + load File.expand_path("../app/serializers/direct_message_channel_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/incoming_chat_webhook_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__) + load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__) + load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__) + load File.expand_path("../lib/chat_mailer.rb", __FILE__) + load File.expand_path("../lib/chat_message_creator.rb", __FILE__) + load File.expand_path("../lib/chat_message_processor.rb", __FILE__) + load File.expand_path("../lib/chat_message_updater.rb", __FILE__) + load File.expand_path("../lib/chat_message_rate_limiter.rb", __FILE__) + load File.expand_path("../lib/chat_message_reactor.rb", __FILE__) + load File.expand_path("../lib/chat_notifier.rb", __FILE__) + load File.expand_path("../lib/chat_seeder.rb", __FILE__) + load File.expand_path("../lib/chat_statistics.rb", __FILE__) + load File.expand_path("../lib/chat_transcript_service.rb", __FILE__) + load File.expand_path("../lib/duplicate_message_validator.rb", __FILE__) + load File.expand_path("../lib/message_mover.rb", __FILE__) + load File.expand_path("../lib/chat_channel_membership_manager.rb", __FILE__) + load File.expand_path("../lib/chat_message_bookmarkable.rb", __FILE__) + load File.expand_path("../lib/chat_channel_archive_service.rb", __FILE__) + load File.expand_path("../lib/chat_review_queue.rb", __FILE__) + load File.expand_path("../lib/direct_message_channel_creator.rb", __FILE__) + load File.expand_path("../lib/guardian_extensions.rb", __FILE__) + load File.expand_path("../lib/extensions/user_option_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_notifications_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_email_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/category_extension.rb", __FILE__) + load File.expand_path("../lib/extensions/user_extension.rb", __FILE__) + load File.expand_path("../lib/slack_compatibility.rb", __FILE__) + load File.expand_path("../lib/post_notification_handler.rb", __FILE__) + load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__) + load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__) + load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__) + load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_channel_archive.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_channel_delete.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_notify_mentioned.rb", __FILE__) + load File.expand_path("../app/jobs/regular/chat_notify_watching.rb", __FILE__) + load File.expand_path("../app/jobs/regular/update_channel_user_count.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/delete_old_chat_messages.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/update_user_counts_for_chat_channels.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/email_chat_notifications.rb", __FILE__) + load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__) + load File.expand_path("../app/services/chat_publisher.rb", __FILE__) + load File.expand_path("../app/controllers/api_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__) + load File.expand_path("../app/controllers/api/chat_channel_memberships_controller.rb", __FILE__) + load File.expand_path( + "../app/controllers/api/chat_channel_notifications_settings_controller.rb", + __FILE__, + ) + load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) + load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) + + if Discourse.allow_dev_populate? + load File.expand_path("../lib/discourse_dev/public_channel.rb", __FILE__) + load File.expand_path("../lib/discourse_dev/direct_channel.rb", __FILE__) + load File.expand_path("../lib/discourse_dev/message.rb", __FILE__) + end + + UserNotifications.append_view_path(File.expand_path("../app/views", __FILE__)) + + register_category_custom_field_type(Chat::HAS_CHAT_ENABLED, :boolean) + + UserUpdater::OPTION_ATTR.push(:chat_enabled) + 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(:chat_email_frequency) + + register_reviewable_type ReviewableChatMessage + + reloadable_patch do |plugin| + ReviewableScore.add_new_types([:needs_review]) + + Site.preloaded_category_custom_fields << Chat::HAS_CHAT_ENABLED + Site.markdown_additional_options["chat"] = { + limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES, + limited_pretty_text_markdown_rules: ChatMessage::MARKDOWN_IT_RULES, + } + + Guardian.prepend Chat::GuardianExtensions + UserNotifications.prepend Chat::UserNotificationsExtension + UserOption.prepend Chat::UserOptionExtension + Category.prepend Chat::CategoryExtension + User.prepend Chat::UserExtension + Jobs::UserEmail.prepend Chat::UserEmailExtension + + Bookmark.register_bookmarkable(ChatMessageBookmarkable) + end + + if Oneboxer.respond_to?(:register_local_handler) + Oneboxer.register_local_handler("chat/chat") do |url, route| + queryParams = + begin + CGI.parse(URI.parse(url).query) + rescue StandardError + {} + end + messageId = queryParams["messageId"]&.first + + if messageId.present? + message = ChatMessage.find_by(id: messageId) + next if !message + + chat_channel = message.chat_channel + user = message.user + next if !chat_channel || !user + else + chat_channel = ChatChannel.find_by(id: route[:channel_id]) + next if !chat_channel + end + + next if !Guardian.new.can_see_chat_channel?(chat_channel) + + name = (chat_channel.name if chat_channel.name.present?) + + users = + chat_channel + .user_chat_channel_memberships + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .limit(10) + .map do |membership| + { + username: membership.user.username, + avatar_url: membership.user.avatar_template_url.gsub("{size}", "60"), + } + end + + remaining_user_count_str = + if chat_channel.user_count > users.size + I18n.t("chat.onebox.and_x_others", count: chat_channel.user_count - users.size) + end + + args = { + url: url, + channel_id: chat_channel.id, + channel_name: name, + description: chat_channel.description, + user_count_str: I18n.t("chat.onebox.x_members", count: chat_channel.user_count), + users: users, + remaining_user_count_str: remaining_user_count_str, + is_category: chat_channel.chatable_type == "Category", + color: chat_channel.chatable_type == "Category" ? chat_channel.chatable.color : nil, + } + + if message.present? + args[:message_id] = message.id + args[:username] = message.user.username + args[:avatar_url] = message.user.avatar_template_url.gsub("{size}", "20") + args[:cooked] = message.cooked + args[:created_at] = message.created_at + args[:created_at_str] = message.created_at.iso8601 + end + + Mustache.render(Chat.onebox_template, args) + end + end + + if InlineOneboxer.respond_to?(:register_local_handler) + InlineOneboxer.register_local_handler("chat/chat") do |url, route| + queryParams = + begin + CGI.parse(URI.parse(url).query) + rescue StandardError + {} + end + messageId = queryParams["messageId"]&.first + + if messageId.present? + message = ChatMessage.find_by(id: messageId) + next if !message + + chat_channel = message.chat_channel + user = message.user + next if !chat_channel || !user + + title = + I18n.t( + "chat.onebox.inline_to_message", + message_id: message.id, + chat_channel: chat_channel.name, + username: user.username, + ) + else + chat_channel = ChatChannel.find_by(id: route[:channel_id]) + next if !chat_channel + + title = + if chat_channel.name.present? + I18n.t("chat.onebox.inline_to_channel", chat_channel: chat_channel.name) + end + end + + next if !Guardian.new.can_see_chat_channel?(chat_channel) + + { url: url, title: title } + end + end + + if respond_to?(:register_upload_unused) + register_upload_unused do |uploads| + uploads.joins("LEFT JOIN chat_uploads cu ON cu.upload_id = uploads.id").where( + "cu.upload_id IS NULL", + ) + end + end + + if respond_to?(:register_upload_in_use) + register_upload_in_use do |upload| + ChatMessage.where( + "message LIKE ? OR message LIKE ?", + "%#{upload.sha1}%", + "%#{upload.base62_sha1}%", + ).exists? || + ChatDraft.where( + "data LIKE ? OR data LIKE ?", + "%#{upload.sha1}%", + "%#{upload.base62_sha1}%", + ).exists? + end + end + + add_to_serializer(:user_card, :can_chat_user) do + return false if !SiteSetting.chat_enabled + return false if scope.user.blank? + + scope.user.id != object.id && scope.can_chat?(scope.user) && scope.can_chat?(object) + end + + add_to_serializer(:current_user, :can_chat) { true } + + add_to_serializer(:current_user, :include_can_chat?) do + return @can_chat if defined?(@can_chat) + + @can_chat = SiteSetting.chat_enabled && scope.can_chat?(object) + end + + add_to_serializer(:current_user, :has_chat_enabled) { true } + + add_to_serializer(:current_user, :include_has_chat_enabled?) do + return @has_chat_enabled if defined?(@has_chat_enabled) + + @has_chat_enabled = include_can_chat? && object.user_option.chat_enabled + end + + add_to_serializer(:current_user, :chat_sound) { object.user_option.chat_sound } + + add_to_serializer(:current_user, :include_chat_sound?) do + include_has_chat_enabled? && object.user_option.chat_sound + end + + add_to_serializer(:current_user, :needs_channel_retention_reminder) { true } + + add_to_serializer(:current_user, :needs_dm_retention_reminder) { true } + + add_to_serializer(:current_user, :has_joinable_public_channels) do + Chat::ChatChannelFetcher.secured_public_channels( + self.scope, + Chat::ChatChannelMembershipManager.all_for_user(self.scope.user), + following: false, + limit: 1, + status: :open, + ).present? + end + + add_to_serializer(:current_user, :chat_channels) do + structured = Chat::ChatChannelFetcher.structured(self.scope) + ChatChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json + end + + add_to_serializer(:current_user, :include_needs_channel_retention_reminder?) do + include_has_chat_enabled? && object.staff? && + !object.user_option.dismissed_channel_retention_reminder && + !SiteSetting.chat_channel_retention_days.zero? + end + + add_to_serializer(:current_user, :include_needs_dm_retention_reminder?) do + include_has_chat_enabled? && !object.user_option.dismissed_dm_retention_reminder && + !SiteSetting.chat_dm_retention_days.zero? + end + + add_to_serializer(:current_user, :chat_drafts) do + ChatDraft + .where(user_id: object.id) + .pluck(:chat_channel_id, :data) + .map { |row| { channel_id: row[0], data: row[1] } } + end + + add_to_serializer(:current_user, :include_chat_drafts?) { include_has_chat_enabled? } + + add_to_serializer(:user_option, :chat_enabled) { object.chat_enabled } + + add_to_serializer(:user_option, :chat_sound) { object.chat_sound } + + add_to_serializer(:user_option, :include_chat_sound?) { !object.chat_sound.blank? } + + add_to_serializer(:user_option, :only_chat_push_notifications) do + object.only_chat_push_notifications + end + + add_to_serializer(:user_option, :ignore_channel_wide_mention) do + object.ignore_channel_wide_mention + end + + add_to_serializer(:user_option, :chat_email_frequency) { object.chat_email_frequency } + + RETENTION_SETTINGS_TO_USER_OPTION_FIELDS = { + chat_channel_retention_days: :dismissed_channel_retention_reminder, + chat_dm_retention_days: :dismissed_dm_retention_reminder, + } + on(:site_setting_changed) do |name, old_value, new_value| + user_option_field = RETENTION_SETTINGS_TO_USER_OPTION_FIELDS[name.to_sym] + begin + if user_option_field && old_value != new_value && !new_value.zero? + UserOption.where(user_option_field => true).update_all(user_option_field => false) + end + rescue => e + Rails.logger.warn( + "Error updating user_options fields after chat retention settings changed: #{e}", + ) + end + + if name == :secure_uploads && old_value == false && new_value == true + Chat::SecureUploadsCompatibility.update_settings + end + end + + on(:post_alerter_after_save_post) do |post, new_record, notified| + next if !new_record + Chat::PostNotificationHandler.new(post, notified).handle + end + + register_presence_channel_prefix("chat") do |channel_name| + next nil unless channel_name == "/chat/online" + config = PresenceChannel::Config.new + config.allowed_group_ids = Chat.allowed_group_ids + config + end + + register_presence_channel_prefix("chat-reply") do |channel_name| + if chat_channel_id = channel_name[%r{/chat-reply/(\d+)}, 1] + chat_channel = ChatChannel.find(chat_channel_id) + + PresenceChannel::Config.new.tap do |config| + config.allowed_group_ids = chat_channel.allowed_group_ids + config.allowed_user_ids = chat_channel.allowed_user_ids + config.public = !chat_channel.read_restricted? + end + end + rescue ActiveRecord::RecordNotFound + nil + end + + register_presence_channel_prefix("chat-user") do |channel_name| + if user_id = channel_name[%r{/chat-user/(chat|core)/(\d+)}, 2] + user = User.find(user_id) + config = PresenceChannel::Config.new + config.allowed_user_ids = [user.id] + config + end + rescue ActiveRecord::RecordNotFound + nil + end + + CHAT_NOTIFICATION_TYPES = [Notification.types[:chat_mention], Notification.types[:chat_message]] + register_push_notification_filter do |user, payload| + if user.user_option.only_chat_push_notifications && user.user_option.chat_enabled + CHAT_NOTIFICATION_TYPES.include?(payload[:notification_type]) + else + true + end + end + + on(:user_seen) do |user| + if user.last_seen_at == user.first_seen_at + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + end + + on(:user_confirmed_email) do |user| + if user.active? + ChatChannel + .where(auto_join_users: true) + .each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + end + + on(:user_added_to_group) do |user, group| + channels_to_add = + ChatChannel + .distinct + .where(auto_join_users: true, chatable_type: "Category") + .joins( + "INNER JOIN category_groups ON category_groups.category_id = chat_channels.chatable_id", + ) + .where(category_groups: { group_id: group.id }) + + channels_to_add.each do |channel| + Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + end + end + + on(:category_updated) do |category| + # TODO(roman): remove early return after 2.9 release. + # There's a bug on core where this event is triggered with an `#update` result (true/false) + return if !category.is_a?(Category) + category_channel = ChatChannel.find_by(auto_join_users: true, chatable: category) + + if category_channel + Chat::ChatChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships + end + end + + Chat::Engine.routes.draw do + namespace :api do + get "/chat_channels" => "chat_channels#index" + get "/chat_channels/:chat_channel_id/memberships" => "chat_channel_memberships#index" + put "/chat_channels/:chat_channel_id" => "chat_channels#update" + put "/chat_channels/:chat_channel_id/notifications_settings" => + "chat_channel_notifications_settings#update" + + # hints controller. Only used by staff members, we don't want to leak category permissions. + get "/category-chatables/:id/permissions" => "category_chatables#permissions", + :format => :json, + :constraints => StaffConstraint.new + end + + # direct_messages_controller routes + get "/direct_messages" => "direct_messages#index" + post "/direct_messages/create" => "direct_messages#create" + + # incoming_webhooks_controller routes + post "/hooks/:key" => "incoming_chat_webhooks#create_message" + + # incoming_webhooks_controller routes + post "/hooks/:key/slack" => "incoming_chat_webhooks#create_message_slack_compatible" + + # chat_channel_controller routes + get "/chat_channels" => "chat_channels#index" + put "/chat_channels" => "chat_channels#create" + get "/chat_channels/search" => "chat_channels#search" + post "/chat_channels/:chat_channel_id" => "chat_channels#edit" + post "/chat_channels/:chat_channel_id/notification_settings" => + "chat_channels#notification_settings" + post "/chat_channels/:chat_channel_id/follow" => "chat_channels#follow" + post "/chat_channels/:chat_channel_id/unfollow" => "chat_channels#unfollow" + get "/chat_channels/:chat_channel_id" => "chat_channels#show" + put "/chat_channels/:chat_channel_id/archive" => "chat_channels#archive" + put "/chat_channels/:chat_channel_id/retry_archive" => "chat_channels#retry_archive" + put "/chat_channels/:chat_channel_id/change_status" => "chat_channels#change_status" + delete "/chat_channels/:chat_channel_id" => "chat_channels#destroy" + + # chat_controller routes + get "/" => "chat#respond" + get "/browse" => "chat#respond" + get "/browse/all" => "chat#respond" + get "/browse/closed" => "chat#respond" + get "/browse/open" => "chat#respond" + get "/browse/archived" => "chat#respond" + get "/draft-channel" => "chat#respond" + get "/channel/:channel_id" => "chat#respond" + get "/channel/:channel_id/:channel_title" => "chat#respond" + get "/channel/:channel_id/:channel_title/info" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/about" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/members" => "chat#respond" + get "/channel/:channel_id/:channel_title/info/settings" => "chat#respond" + post "/enable" => "chat#enable_chat" + post "/disable" => "chat#disable_chat" + post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" + get "/:chat_channel_id/messages" => "chat#messages" + get "/message/:message_id" => "chat#message_link" + put ":chat_channel_id/edit/:message_id" => "chat#edit_message" + put ":chat_channel_id/react/:message_id" => "chat#react" + delete "/:chat_channel_id/:message_id" => "chat#delete" + put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" + post "/:chat_channel_id/:message_id/flag" => "chat#flag" + post "/:chat_channel_id/quote" => "chat#quote_messages" + put "/:chat_channel_id/move_messages_to_channel" => "chat#move_messages_to_channel" + put "/:chat_channel_id/restore/:message_id" => "chat#restore" + get "/lookup/:message_id" => "chat#lookup_message" + put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" + put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" + put "/:chat_channel_id/invite" => "chat#invite_users" + post "/drafts" => "chat#set_draft" + post "/:chat_channel_id" => "chat#create_message" + put "/flag" => "chat#flag" + get "/emojis" => "emojis#index" + end + + Discourse::Application.routes.append do + mount ::Chat::Engine, at: "/chat" + get "/admin/plugins/chat" => "chat/admin_incoming_chat_webhooks#index", + :constraints => StaffConstraint.new + post "/admin/plugins/chat/hooks" => "chat/admin_incoming_chat_webhooks#create", + :constraints => StaffConstraint.new + put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => + "chat/admin_incoming_chat_webhooks#update", + :constraints => StaffConstraint.new + delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => + "chat/admin_incoming_chat_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 + field :message, component: :message, required: true, accepts_placeholders: true + field :sender, component: :user + + placeholder :channel_name + + script do |context, fields, automation| + sender = User.find_by(username: fields.dig("sender", "value")) || Discourse.system_user + channel = ChatChannel.find_by(id: fields.dig("chat_channel_id", "value")) + + placeholders = { channel_name: channel.public_channel_title }.merge( + context["placeholders"] || {}, + ) + + creator = + Chat::ChatMessageCreator.create( + chat_channel: channel, + user: sender, + content: utils.apply_placeholders(fields.dig("message", "value"), placeholders), + ) + + if creator.failed? + Rails.logger.warn "[discourse-automation] Chat message failed to send, error was: #{creator.error}" + end + end + end + end + + add_api_key_scope( + :chat, + { + create_message: { + actions: %w[chat/chat#create_message], + params: %i[chat_channel_id], + }, + }, + ) + + # Dark mode email styles + Email::Styles.register_plugin_style do |fragment| + fragment.css(".chat-summary-header").each { |element| element[:dm] = "header" } + fragment.css(".chat-summary-content").each { |element| element[:dm] = "body" } + end + + # TODO(roman): Remove `respond_to?` after 2.9 release + if respond_to?(:register_email_unsubscriber) + load File.expand_path("../lib/email_controller_helper/chat_summary_unsubscriber.rb", __FILE__) + register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) + end + + register_about_stat_group("chat_messages", show_in_ui: true) { Chat::Statistics.about_messages } + + register_about_stat_group("chat_channels") { Chat::Statistics.about_channels } + + register_about_stat_group("chat_users") { Chat::Statistics.about_users } +end + +if Rails.env == "test" + Dir[Rails.root.join("plugins/chat/spec/support/**/*.rb")].each { |f| require f } +end diff --git a/plugins/chat/public/audio/bell.mp3 b/plugins/chat/public/audio/bell.mp3 new file mode 100644 index 00000000000..1f5a34a427a Binary files /dev/null and b/plugins/chat/public/audio/bell.mp3 differ diff --git a/plugins/chat/public/audio/ding.mp3 b/plugins/chat/public/audio/ding.mp3 new file mode 100644 index 00000000000..82c885ce073 Binary files /dev/null and b/plugins/chat/public/audio/ding.mp3 differ diff --git a/plugins/chat/public/images/deleted-chat-user-avatar.png b/plugins/chat/public/images/deleted-chat-user-avatar.png new file mode 100644 index 00000000000..88146bdc28c Binary files /dev/null and b/plugins/chat/public/images/deleted-chat-user-avatar.png differ diff --git a/plugins/chat/spec/components/chat_mailer_spec.rb b/plugins/chat/spec/components/chat_mailer_spec.rb new file mode 100644 index 00000000000..3b74d34ddfb --- /dev/null +++ b/plugins/chat/spec/components/chat_mailer_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMailer do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } + fab!(:user_1) { Fabricate(:user, group_ids: [chatters_group.id], last_seen_at: 15.minutes.ago) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, user: sender, chat_channel: chat_channel) } + fab!(:user_1_chat_channel_membership) do + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: chat_channel, + last_read_message_id: nil, + ) + end + fab!(:private_chat_channel) do + Group.refresh_automatic_groups! + Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user_1]) + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = chatters_group.id + + Fabricate(:user_chat_channel_membership, user: sender, chat_channel: chat_channel) + end + + def assert_summary_skipped + expect( + job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }), + ).to eq(false) + end + + def assert_only_queued_once + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + describe "for chat mentions" do + fab!(:mention) { Fabricate(:chat_mention, user: user_1, chat_message: chat_message) } + + it "skips users without chat access" do + chatters_group.remove(user_1) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips users with summaries disabled" do + user_1.user_option.update(chat_email_frequency: UserOption.chat_email_frequencies[:never]) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips a job if the user haven't read the channel since the last summary" do + user_1_chat_channel_membership.update!(last_unread_mention_when_emailed_id: chat_message.id) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips without chat enabled" do + user_1.user_option.update( + chat_enabled: false, + chat_email_frequency: UserOption.chat_email_frequencies[:when_away], + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "queues a job for users that was mentioned and never read the channel before" do + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips the job when the user was mentioned but already read the message" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "skips the job when the user is not following a public channel anymore" do + user_1_chat_channel_membership.update!( + last_read_message_id: chat_message.id - 1, + following: false, + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "doesn’t skip the job when the user is not following a direct channel" do + private_chat_channel + .user_chat_channel_memberships + .where(user_id: user_1.id) + .update!(last_read_message_id: chat_message.id - 1, following: false) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips users with unread messages from a different channel" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id) + second_channel = Fabricate(:category_channel) + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: second_channel, + last_read_message_id: chat_message.id - 1, + ) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "only queues the job once for users who are member of multiple groups with chat access" do + chatters_group_2 = Fabricate(:group, users: [user_1]) + SiteSetting.chat_allowed_groups = [chatters_group, chatters_group_2].map(&:id).join("|") + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "skips users when the mention was deleted" do + chat_message.trash! + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "queues the job if the user has unread mentions and already read all the messages in the previous summary" do + user_1_chat_channel_membership.update!( + last_read_message_id: chat_message.id, + last_unread_mention_when_emailed_id: chat_message.id, + ) + unread_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: unread_message) + + described_class.send_unread_mentions_summary + + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + it "skips users who were seen recently" do + user_1.update!(last_seen_at: 2.minutes.ago) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + it "doesn't mix mentions from other users" do + mention.destroy! + user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago) + user_2_membership = + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: chat_channel, + last_read_message_id: nil, + ) + new_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_2, chat_message: new_message) + + described_class.send_unread_mentions_summary + + expect( + job_enqueued?(job: :user_email, args: { type: "chat_summary", user_id: user_1.id }), + ).to eq(false) + expect_job_enqueued(job: :user_email, args: { type: "chat_summary", user_id: user_2.id }) + expect(Jobs::UserEmail.jobs.size).to eq(1) + end + + it "skips users when the message is older than 1 week" do + chat_message.update!(created_at: 1.5.weeks.ago) + + described_class.send_unread_mentions_summary + + assert_summary_skipped + end + + describe "update the user membership after we send the email" do + before { Jobs.run_immediately! } + + it "doesn't send the same summary the summary again if the user haven't read any channel messages since the last one" do + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1) + described_class.send_unread_mentions_summary + + expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq( + chat_message.id, + ) + + another_channel_message = Fabricate(:chat_message, chat_channel: chat_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: another_channel_message) + + expect { described_class.send_unread_mentions_summary }.not_to change( + Jobs::UserEmail.jobs, + :size, + ) + end + + it "only updates the last_message_read_when_emailed_id on the channel with unread mentions" do + another_channel = Fabricate(:category_channel) + another_channel_message = + Fabricate(:chat_message, chat_channel: another_channel, user: sender) + Fabricate(:chat_mention, user: user_1, chat_message: another_channel_message) + another_channel_membership = + Fabricate( + :user_chat_channel_membership, + user: user_1, + chat_channel: another_channel, + last_read_message_id: another_channel_message.id, + ) + user_1_chat_channel_membership.update!(last_read_message_id: chat_message.id - 1) + + described_class.send_unread_mentions_summary + + expect(user_1_chat_channel_membership.reload.last_unread_mention_when_emailed_id).to eq( + chat_message.id, + ) + expect(another_channel_membership.reload.last_unread_mention_when_emailed_id).to be_nil + end + end + end + + describe "for direct messages" do + before { Fabricate(:chat_message, user: sender, chat_channel: private_chat_channel) } + + it "queue a job when the user has unread private mentions" do + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "only queues the job once when the user has mentions and private messages" do + Fabricate(:chat_mention, user: user_1, chat_message: chat_message) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + end + + it "Doesn't mix or update mentions from other users when joining tables" do + user_2 = Fabricate(:user, groups: [chatters_group], last_seen_at: 20.minutes.ago) + user_2_membership = + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: chat_channel, + last_read_message_id: chat_message.id, + ) + Fabricate(:chat_mention, user: user_2, chat_message: chat_message) + + described_class.send_unread_mentions_summary + + assert_only_queued_once + expect(user_2_membership.reload.last_unread_mention_when_emailed_id).to be_nil + end + end +end diff --git a/plugins/chat/spec/components/chat_message_creator_spec.rb b/plugins/chat/spec/components/chat_message_creator_spec.rb new file mode 100644 index 00000000000..95567408dac --- /dev/null +++ b/plugins/chat/spec/components/chat_message_creator_spec.rb @@ -0,0 +1,555 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageCreator do + fab!(:admin1) { Fabricate(:admin) } + fab!(:admin2) { Fabricate(:admin) } + fab!(:user1) { Fabricate(:user, group_ids: [Group::AUTO_GROUPS[:everyone]]) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:admin_group) do + Fabricate( + :public_group, + users: [admin1, admin2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_group) do + Fabricate( + :public_group, + users: [user1, user2, user3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_without_memberships) { Fabricate(:user) } + fab!(:public_chat_channel) { Fabricate(:category_channel) } + fab!(:dm_chat_channel) do + Fabricate( + :dm_channel, + chatable: Fabricate(:direct_message_channel, users: [user1, user2, user3]), + ) + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + + # Create channel memberships + [admin1, admin2, user1, user2, user3].each do |user| + Fabricate(:user_chat_channel_membership, chat_channel: public_chat_channel, user: user) + end + + Group.refresh_automatic_groups! + @direct_message_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: user1, target_users: [user1, user2]) + end + + describe "Integration tests with jobs running immediately" do + before { Jobs.run_immediately! } + + it "errors when length is less than `chat_minimum_message_length`" do + SiteSetting.chat_minimum_message_length = 10 + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "2 short", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t( + "chat.errors.minimum_length_not_met", + { minimum: SiteSetting.chat_minimum_message_length }, + ), + ) + end + + it "allows message creation when length is less than `chat_minimum_message_length` when upload is present" do + upload = Fabricate(:upload, user: user1) + SiteSetting.chat_minimum_message_length = 10 + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "2 short", + upload_ids: [upload.id], + ) + }.to change { ChatMessage.count }.by(1) + end + + it "creates messages for users who can see the channel" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "this is a message", + ) + }.to change { ChatMessage.count }.by(1) + end + + it "creates mention notifications for public chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: + "this is a @#{user1.username} message with @system @mentions @#{user2.username} and @#{user3.username}", + ) + # Only 2 mentions are created because user mentioned themselves, system, and an invalid username. + }.to change { ChatMention.count }.by(2).and not_change { user1.chat_mentions.count } + end + + it "mentions are case insensitive" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hey @#{user2.username.upcase}", + ) + }.to change { user2.chat_mentions.count }.by(1) + end + + it "notifies @all properly" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@all", + ) + }.to change { ChatMention.count }.by(4) + + UserChatChannelMembership.where(user: user2, chat_channel: public_chat_channel).update_all( + following: false, + ) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "again! @all", + ) + }.to change { ChatMention.count }.by(3) + end + + it "notifies @here properly" do + admin1.update(last_seen_at: 1.year.ago) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: Time.now) + user2.update(last_seen_at: Time.now) + user3.update(last_seen_at: Time.now) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ) + }.to change { ChatMention.count }.by(2) + end + + it "doesn't sent double notifications when '@here' is mentioned" do + user2.update(last_seen_at: Time.now) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here @#{user2.username}", + ) + }.to change { user2.chat_mentions.count }.by(1) + end + + it "notifies @here plus other mentions" do + admin1.update(last_seen_at: Time.now) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: 1.year.ago) + user2.update(last_seen_at: 1.year.ago) + user3.update(last_seen_at: 1.year.ago) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here plus @#{user3.username}", + ) + }.to change { user3.chat_mentions.count }.by(1) + end + + it "doesn't create mention notifications for users without a membership record" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{user_without_memberships.username}", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mention notifications for users who cannot chat" do + new_group = Group.create + SiteSetting.chat_allowed_groups = new_group.id + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username} @#{user3.username}", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mention notifications for users with chat disabled" do + user2.user_option.update(chat_enabled: false) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi @#{user2.username}", + ) + }.not_to change { ChatMention.count } + end + + it "creates only mention notifications for users with access in private chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: @direct_message_channel, + user: user1, + content: "hello there @#{user2.username} and @#{user3.username}", + ) + # Only user2 should be notified + }.to change { user2.chat_mentions.count }.by(1).and not_change { user3.chat_mentions.count } + end + + it "creates a mention notifications for group users that are participating in private chat" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: @direct_message_channel, + user: user1, + content: "hello there @#{user_group.name}", + ) + # Only user2 should be notified + }.to change { user2.chat_mentions.count }.by(1).and not_change { user3.chat_mentions.count } + end + + it "publishes inaccessible mentions when user isn't aren't a part of the channel" do + ChatPublisher.expects(:publish_inaccessible_mentions).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{user4.username}", + ) + end + + it "publishes inaccessible mentions when user doesn't have chat access" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + ChatPublisher.expects(:publish_inaccessible_mentions).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{user3.username}", + ) + end + + it "doesn't publish inaccessible mentions when user is following channel" do + ChatPublisher.expects(:publish_inaccessible_mentions).never + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: admin1, + content: "hello @#{admin2.username}", + ) + end + + it "does not create mentions for suspended users" do + user2.update(suspended_till: Time.now + 10.years) + expect { + Chat::ChatMessageCreator.create( + chat_channel: @direct_message_channel, + user: user1, + content: "hello @#{user2.username}", + ) + }.not_to change { user2.chat_mentions.count } + end + + it "does not create @all mentions for users when ignore_channel_wide_mention is enabled" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@all", + ) + }.to change { ChatMention.count }.by(4) + + user2.user_option.update(ignore_channel_wide_mention: true) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hi! @all", + ) + }.to change { ChatMention.count }.by(3) + end + + it "does not create @here mentions for users when ignore_channel_wide_mention is enabled" do + admin1.update(last_seen_at: 1.year.ago) + admin2.update(last_seen_at: 1.year.ago) + user1.update(last_seen_at: Time.now) + user2.update(last_seen_at: Time.now) + user2.user_option.update(ignore_channel_wide_mention: true) + user3.update(last_seen_at: Time.now) + + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "@here", + ) + }.to change { ChatMention.count }.by(1) + end + + describe "group mentions" do + it "creates chat mentions for group mentions where the group is mentionable" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1) + end + + it "doesn't mention users twice if they are direct mentioned and group mentioned" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{admin1.username} and @#{admin2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1) + end + + it "creates chat mentions for group mentions and direct mentions" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{user2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1).and change { user2.chat_mentions.count }.by(1) + end + + it "creates chat mentions for group mentions and direct mentions" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name} @#{user_group.name}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and change { + admin2.chat_mentions.count + }.by(1).and change { user2.chat_mentions.count }.by(1).and change { + user3.chat_mentions.count + }.by(1) + end + + it "doesn't create chat mentions for group mentions where the group is un-mentionable" do + admin_group.update(mentionable_level: Group::ALIAS_LEVELS[:nobody]) + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "hello @#{admin_group.name}", + ) + }.not_to change { ChatMention.count } + end + end + + describe "push notifications" do + before do + UserChatChannelMembership.where(user: user1, chat_channel: public_chat_channel).update( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + PresenceChannel.clear_all! + end + + it "sends a push notification to watching users who are not in chat" do + PostAlerter.expects(:push_notification).once + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user2, + content: "Beep boop", + ) + end + + it "does not send a push notification to watching users who are in chat" do + PresenceChannel.new("/chat/online").present(user_id: user1.id, client_id: 1) + PostAlerter.expects(:push_notification).never + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user2, + content: "Beep boop", + ) + end + end + + describe "with uploads" do + fab!(:upload1) { Fabricate(:upload, user: user1) } + fab!(:upload2) { Fabricate(:upload, user: user1) } + fab!(:private_upload) { Fabricate(:upload, user: user2) } + + it "can attach 1 upload to a new message" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(upload_id: upload1.id).count }.by(1) + end + + it "can attach multiple uploads to a new message" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id, upload2.id], + ) + }.to change { ChatUpload.where(upload_id: upload1.id).count }.by(1).and change { + ChatUpload.where(upload_id: upload2.id).count + }.by(1) + end + + it "filters out uploads that weren't uploaded by the user" do + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [private_upload.id], + ) + }.not_to change { ChatUpload.where(upload_id: private_upload.id).count } + end + + it "doesn't attach uploads when `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + expect { + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Beep boop", + upload_ids: [upload1.id], + ) + }.not_to change { ChatUpload.where(upload_id: upload1.id).count } + end + end + end + + it "destroys draft after message was created" do + ChatDraft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") + + expect do + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "Hi @#{user2.username}", + ) + end.to change { ChatDraft.count }.by(-1) + end + + describe "watched words" do + fab!(:watched_word) { Fabricate(:watched_word) } + + it "errors when a blocked word is present" do + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "bad word - #{watched_word.word}", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t("contains_blocked_word", { word: watched_word.word }), + ) + end + end + + describe "channel statuses" do + def create_message(user) + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user, + content: "test message", + ) + end + + context "when channel is closed" do + before { public_chat_channel.update(status: :closed) } + + it "errors when trying to create the message for non-staff" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + + it "does not error when trying to create a message for staff" do + expect { create_message(admin1) }.to change { ChatMessage.count }.by(1) + end + end + + context "when channel is read_only" do + before { public_chat_channel.update(status: :read_only) } + + it "errors when trying to create the message for all users" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + + context "when channel is archived" do + before { public_chat_channel.update(status: :archived) } + + it "errors when trying to create the message for all users" do + creator = create_message(user1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + creator = create_message(admin1) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to eq( + I18n.t( + "chat.errors.channel_new_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + end +end diff --git a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb b/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb new file mode 100644 index 00000000000..b91616a3592 --- /dev/null +++ b/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageRateLimiter do + fab!(:user) { Fabricate(:user, trust_level: 3) } + let(:limiter) { described_class.new(user) } + + before do + freeze_time + RateLimiter.enable + SiteSetting.chat_allowed_messages_for_trust_level_0 = 1 + SiteSetting.chat_allowed_messages_for_other_trust_levels = 2 + SiteSetting.chat_auto_silence_duration = 30 + end + + after { limiter.clear! } + + it "does nothing when rate limits are not exceeded" do + limiter.run! + expect(user.reload.silenced?).to be false + end + + it "silences the user for the correct amount of time when they exceed the limit" do + 2.times do + limiter.run! + expect(user.reload.silenced?).to be false + end + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + + expect(user.reload.silenced?).to be true + expect(user.silenced_till).to be_within(0.1).of(30.minutes.from_now) + end + + it "silences the user correctly based on trust level" do + user.update(trust_level: 0) # Should only be able to run once without hitting limit + limiter.run! + expect(user.reload.silenced?).to be false + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + expect(user.reload.silenced?).to be true + end + + it "doesn't hit limit if site setting for allowed messages equals 0" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 0 + 5.times do + limiter.run! + expect(user.reload.silenced?).to be false + end + end + + it "doesn't silence the user even when the limit is broken if auto_silence_duration is set to 0" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 1 + SiteSetting.chat_auto_silence_duration = 0 + limiter.run! + expect(user.reload.silenced?).to be false + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded) + expect(user.reload.silenced?).to be false + end + + it "logs a staff action when the user is silenced" do + SiteSetting.chat_allowed_messages_for_other_trust_levels = 1 + limiter.run! + + expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded).and change { + UserHistory.where( + target_user: user, + acting_user: Discourse.system_user, + action: UserHistory.actions[:silence_user], + ).count + }.by(1) + end +end diff --git a/plugins/chat/spec/components/chat_message_updater_spec.rb b/plugins/chat/spec/components/chat_message_updater_spec.rb new file mode 100644 index 00000000000..048de7aae9e --- /dev/null +++ b/plugins/chat/spec/components/chat_message_updater_spec.rb @@ -0,0 +1,422 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageUpdater do + fab!(:admin1) { Fabricate(:admin) } + fab!(:admin2) { Fabricate(:admin) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:admin_group) do + Fabricate( + :public_group, + users: [admin1, admin2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:user_without_memberships) { Fabricate(:user) } + fab!(:public_chat_channel) { Fabricate(:category_channel) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + Jobs.run_immediately! + + [admin1, admin2, user1, user2, user3, user4].each do |user| + Fabricate(:user_chat_channel_membership, chat_channel: public_chat_channel, user: user) + end + Group.refresh_automatic_groups! + @direct_message_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: user1, target_users: [user1, user2]) + end + + def create_chat_message(user, message, channel, upload_ids: nil) + creator = + Chat::ChatMessageCreator.create( + chat_channel: channel, + user: user, + in_reply_to_id: nil, + content: message, + upload_ids: upload_ids, + ) + creator.chat_message + end + + it "errors when length is less than `chat_minimum_message_length`" do + SiteSetting.chat_minimum_message_length = 10 + og_message = "This won't be changed!" + chat_message = create_chat_message(user1, og_message, public_chat_channel) + new_message = "2 short" + + updater = Chat::ChatMessageUpdater.update(chat_message: chat_message, new_content: new_message) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to match( + I18n.t( + "chat.errors.minimum_length_not_met", + { minimum: SiteSetting.chat_minimum_message_length }, + ), + ) + expect(chat_message.reload.message).to eq(og_message) + end + + it "it updates a messages content" do + chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) + new_message = "Change to this!" + + Chat::ChatMessageUpdater.update(chat_message: chat_message, new_content: new_message) + expect(chat_message.reload.message).to eq(new_message) + end + + it "creates mention notifications for unmentioned users" do + chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: + "this is a message with @system @mentions @#{user2.username} and @#{user3.username}", + ) + }.to change { user2.chat_mentions.count }.by(1).and change { user3.chat_mentions.count }.by(1) + end + + it "doesn't create mentions for already mentioned users" do + message = "ping @#{user2.username} @#{user3.username}" + chat_message = create_chat_message(user1, message, public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: message + " editedddd", + ) + }.not_to change { ChatMention.count } + end + + it "doesn't create mentions for users without access" do + message = "ping" + chat_message = create_chat_message(user1, message, public_chat_channel) + + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: message + " @#{user_without_memberships.username}", + ) + }.not_to change { ChatMention.count } + end + + it "destroys mention notifications that should be removed" do + chat_message = + create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping @#{user3.username}", + ) + }.to change { user2.chat_mentions.count }.by(-1).and not_change { user3.chat_mentions.count } + end + + it "creates new, leaves existing, and removes old mentions all at once" do + chat_message = + create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping @#{user3.username} @#{user4.username}", + ) + + expect(user2.chat_mentions.where(chat_message: chat_message)).not_to be_present + expect(user3.chat_mentions.where(chat_message: chat_message)).to be_present + expect(user4.chat_mentions.where(chat_message: chat_message)).to be_present + end + + it "does not create new mentions in direct message for users who don't have access" do + chat_message = create_chat_message(user1, "ping nobody", @direct_message_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping @#{admin1.username}", + ) + }.not_to change { ChatMention.count } + end + + describe "group mentions" do + it "creates group mentions on update" do + chat_message = create_chat_message(user1, "ping nobody", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping @#{admin_group.name}", + ) + }.to change { ChatMention.where(chat_message: chat_message).count }.by(2) + + expect(admin1.chat_mentions.where(chat_message: chat_message)).to be_present + expect(admin2.chat_mentions.where(chat_message: chat_message)).to be_present + end + + it "doesn't duplicate mentions when the user is already direct mentioned and then group mentioned" do + chat_message = create_chat_message(user1, "ping @#{admin2.username}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping @#{admin_group.name} @#{admin2.username}", + ) + }.to change { admin1.chat_mentions.count }.by(1).and not_change { admin2.chat_mentions.count } + end + + it "deletes old mentions when group mention is removed" do + chat_message = create_chat_message(user1, "ping @#{admin_group.name}", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "ping nobody anymore!", + ) + }.to change { ChatMention.where(chat_message: chat_message).count }.by(-2) + + expect(admin1.chat_mentions.where(chat_message: chat_message)).not_to be_present + expect(admin2.chat_mentions.where(chat_message: chat_message)).not_to be_present + end + end + + it "creates a chat_message_revision record" do + old_message = "It's a thrsday!" + new_message = "It's a thursday!" + chat_message = create_chat_message(user1, old_message, public_chat_channel) + Chat::ChatMessageUpdater.update(chat_message: chat_message, new_content: new_message) + revision = chat_message.revisions.last + expect(revision.old_message).to eq(old_message) + expect(revision.new_message).to eq(new_message) + end + + describe "uploads" do + fab!(:upload1) { Fabricate(:upload, user: user1) } + fab!(:upload2) { Fabricate(:upload, user: user1) } + + it "does nothing if the passed in upload_ids match the existing upload_ids" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload2.id, upload1.id], + ) + }.not_to change { ChatUpload.count } + end + + it "removes uploads that should be removed" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(upload_id: upload2.id).count }.by(-1) + end + + it "removes all uploads if they should be removed" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(-2) + end + + it "adds one upload if none exist" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(1) + end + + it "adds multiple uploads if none exist" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id, upload2.id], + ) + }.to change { ChatUpload.where(chat_message: chat_message).count }.by(2) + end + + it "doesn't remove existing uploads when BS upload ids are passed in" do + chat_message = + create_chat_message(user1, "something", public_chat_channel, upload_ids: [upload1.id]) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [0], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "doesn't add uploads if `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + chat_message = create_chat_message(user1, "something", public_chat_channel) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [upload1.id, upload2.id], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "doesn't remove existing uploads if `chat_allow_uploads` is false" do + SiteSetting.chat_allow_uploads = false + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + expect { + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: "I guess this is different", + upload_ids: [], + ) + }.not_to change { ChatUpload.where(chat_message: chat_message).count } + end + + it "updates if upload is present even if length is less than `chat_minimum_message_length`" do + chat_message = + create_chat_message( + user1, + "something", + public_chat_channel, + upload_ids: [upload1.id, upload2.id], + ) + SiteSetting.chat_minimum_message_length = 10 + new_message = "hi :)" + Chat::ChatMessageUpdater.update( + chat_message: chat_message, + new_content: new_message, + upload_ids: [upload1.id], + ) + expect(chat_message.reload.message).to eq(new_message) + end + end + + describe "watched words" do + fab!(:watched_word) { Fabricate(:watched_word) } + + it "errors when a blocked word is present" do + chat_message = create_chat_message(user1, "something", public_chat_channel) + creator = + Chat::ChatMessageCreator.create( + chat_channel: public_chat_channel, + user: user1, + content: "bad word - #{watched_word.word}", + ) + expect(creator.failed?).to eq(true) + expect(creator.error.message).to match( + I18n.t("contains_blocked_word", { word: watched_word.word }), + ) + end + end + + describe "channel statuses" do + fab!(:message) { Fabricate(:chat_message, user: user1, chat_channel: public_chat_channel) } + + def update_message(user) + message.update(user: user) + Chat::ChatMessageUpdater.update( + chat_message: message, + new_content: "I guess this is different", + ) + end + + context "when channel is closed" do + before { public_chat_channel.update(status: :closed) } + + it "errors when trying to update the message for non-staff" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + + it "does not error when trying to create a message for staff" do + update_message(admin1) + expect(message.reload.message).to eq("I guess this is different") + end + end + + context "when channel is read_only" do + before { public_chat_channel.update(status: :read_only) } + + it "errors when trying to update the message for all users" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + updater = update_message(admin1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + + context "when channel is archived" do + before { public_chat_channel.update(status: :archived) } + + it "errors when trying to update the message for all users" do + updater = update_message(user1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + updater = update_message(admin1) + expect(updater.failed?).to eq(true) + expect(updater.error.message).to eq( + I18n.t( + "chat.errors.channel_modify_message_disallowed", + status: public_chat_channel.status_name, + ), + ) + end + end + end +end diff --git a/plugins/chat/spec/components/chat_seeder_spec.rb b/plugins/chat/spec/components/chat_seeder_spec.rb new file mode 100644 index 00000000000..e0a7c5222a6 --- /dev/null +++ b/plugins/chat/spec/components/chat_seeder_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatSeeder do + fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) } + fab!(:general_category) { Fabricate(:category, name: "General") } + + fab!(:staff_user1) do + Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]]) + end + fab!(:staff_user2) do + Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]]) + end + + fab!(:regular_user) { Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:everyone]]) } + + before do + SiteSetting.staff_category_id = staff_category.id + SiteSetting.general_category_id = general_category.id + Jobs.run_immediately! + end + + def assert_channel_was_correctly_seeded(channel, group) + expect(channel).to be_present + expect(channel.auto_join_users).to eq(true) + + expected_members_count = GroupUser.where(group: group).count + memberships_count = + UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count + + expect(memberships_count).to eq(expected_members_count) + end + + it "seeds default channels" do + ChatSeeder.new.execute + + staff_channel = ChatChannel.find_by(chatable: staff_category) + general_channel = ChatChannel.find_by(chatable: general_category) + + assert_channel_was_correctly_seeded(staff_channel, Group[:staff]) + assert_channel_was_correctly_seeded(general_channel, Group[:everyone]) + + expect(staff_category.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true) + expect(general_category.reload.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true) + expect(SiteSetting.needs_chat_seeded).to eq(false) + end + + it "applies a name to the general category channel" do + expected_name = general_category.name + + ChatSeeder.new.execute + + general_channel = ChatChannel.find_by(chatable: general_category) + expect(general_channel.name).to eq(expected_name) + end + + it "applies a name to the staff category channel" do + expected_name = staff_category.name + + ChatSeeder.new.execute + + staff_channel = ChatChannel.find_by(chatable: staff_category) + expect(staff_channel.name).to eq(expected_name) + end + + it "does nothing when 'SiteSetting.needs_chat_seeded' is false" do + SiteSetting.needs_chat_seeded = false + + expect { ChatSeeder.new.execute }.not_to change { ChatChannel.count } + end +end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb new file mode 100644 index 00000000000..0d492c7d70a --- /dev/null +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +Fabricator(:chat_channel) do + name do + ["Gaming Lounge", "Music Lodge", "Random", "Politics", "Sports Center", "Kino Buffs"].sample + end + chatable { Fabricate(:category) } + status { :open } +end + +Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {} + +Fabricator(:dm_channel, from: :chat_channel, class_name: :d_m_channel) do + chatable { Fabricate(:direct_message_channel) } +end + +Fabricator(:direct_message_chat_channel, from: :chat_channel, class_name: :d_m_channel) do + transient :users + chatable do |attrs| + Fabricate(:direct_message_channel, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) + end + status { :open } +end + +Fabricator(:chat_message) do + chat_channel + user + message "Beep boop" + cooked { |attrs| ChatMessage.cook(attrs[:message]) } + cooked_version ChatMessage::BAKED_VERSION +end + +Fabricator(:chat_mention) do + chat_message { Fabricate(:chat_message) } + user { Fabricate(:user) } + notification { Fabricate(:notification) } +end + +Fabricator(:chat_message_reaction) do + chat_message { Fabricate(:chat_message) } + user { Fabricate(:user) } + emoji { %w[+1 tada heart joffrey_facepalm].sample } +end + +Fabricator(:chat_upload) do + chat_message { Fabricate(:chat_message) } + upload { Fabricate(:upload) } +end + +Fabricator(:chat_message_revision) do + chat_message { Fabricate(:chat_message) } + old_message { "something old" } + new_message { "something new" } +end + +Fabricator(:reviewable_chat_message) do + reviewable_by_moderator true + type "ReviewableChatMessage" + created_by { Fabricate(:user) } + target_type "ChatMessage" + target { Fabricate(:chat_message) } + reviewable_scores { |p| [Fabricate.build(:reviewable_score, reviewable_id: p[:id])] } +end + +Fabricator(:direct_message_channel) { users { [Fabricate(:user), Fabricate(:user)] } } + +Fabricator(:chat_webhook_event) do + chat_message { Fabricate(:chat_message) } + incoming_chat_webhook do |attrs| + Fabricate(:incoming_chat_webhook, chat_channel: attrs[:chat_message].chat_channel) + end +end + +Fabricator(:incoming_chat_webhook) do + name { sequence(:name) { |i| "#{i + 1}" } } + key { sequence(:key) { |i| "#{i + 1}" } } + chat_channel { Fabricate(:chat_channel, chatable: Fabricate(:category)) } +end + +Fabricator(:user_chat_channel_membership) do + user + chat_channel + following true +end + +Fabricator(:user_chat_channel_membership_for_dm, from: :user_chat_channel_membership) do + user + chat_channel + following true + desktop_notification_level 2 + mobile_notification_level 2 +end diff --git a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb new file mode 100644 index 00000000000..6fa39be8848 --- /dev/null +++ b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "API keys scoped to chat#create_message" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:admin) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_channel_2) { Fabricate(:category_channel) } + + let(:chat_api_key) do + key = ApiKey.create! + ApiKeyScope.create!(resource: "chat", action: "create_message", api_key_id: key.id) + key + end + + let(:chat_channel_2_api_key) do + key = ApiKey.create! + ApiKeyScope.create!( + resource: "chat", + action: "create_message", + api_key_id: key.id, + allowed_parameters: { + "chat_channel_id" => [chat_channel_2.id.to_s], + }, + ) + key + end + + it "cannot hit any other endpoints" do + get "/admin/users/list/active.json", + headers: { + "Api-Key" => chat_api_key.key, + "Api-Username" => admin.username, + } + expect(response.status).to eq(404) + + get "/latest.json", headers: { "Api-Key" => chat_api_key.key, "Api-Username" => admin.username } + expect(response.status).to eq(403) + end + + it "can create chat messages" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + expect { + post "/chat/#{chat_channel.id}.json", + headers: { + "Api-Key" => chat_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + end + + it "cannot post in a channel it is not scoped for" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + expect { + post "/chat/#{chat_channel.id}.json", + headers: { + "Api-Key" => chat_channel_2_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(403) + end + + it "can only post in scoped channels" do + UserChatChannelMembership.create(user: admin, chat_channel: chat_channel_2, following: true) + expect { + post "/chat/#{chat_channel_2.id}.json", + headers: { + "Api-Key" => chat_channel_2_api_key.key, + "Api-Username" => admin.username, + }, + params: { + message: "asdfasdf asdfasdf", + } + }.to change { ChatMessage.where(chat_channel: chat_channel_2).count }.by(1) + expect(response.status).to eq(200) + end +end diff --git a/plugins/chat/spec/integration/plugin_api_spec.rb b/plugins/chat/spec/integration/plugin_api_spec.rb new file mode 100644 index 00000000000..a329d906824 --- /dev/null +++ b/plugins/chat/spec/integration/plugin_api_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Plugin API for chat" do + before { SiteSetting.chat_enabled = true } + + let(:metadata) do + metadata = Plugin::Metadata.new + metadata.name = "test" + metadata + end + + let(:plugin_instance) do + plugin = Plugin::Instance.new(nil, "/tmp/test.rb") + plugin.metadata = metadata + plugin + end + + describe "chat.enable_markdown_feature" do + it "stores the markdown feature" do + plugin_instance.chat.enable_markdown_feature(:foo) + + expect(DiscoursePluginRegistry.chat_markdown_features.include?(:foo)).to be_truthy + end + end +end diff --git a/plugins/chat/spec/integration/post_chat_quote_spec.rb b/plugins/chat/spec/integration/post_chat_quote_spec.rb new file mode 100644 index 00000000000..1c1e0430bd5 --- /dev/null +++ b/plugins/chat/spec/integration/post_chat_quote_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +describe "chat bbcode quoting in posts" do + fab!(:post) { Fabricate(:post) } + + before { SiteSetting.chat_enabled = true } + + it "can render the simplest version" do + post.update!( + raw: "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+
+
+ martin
+
+ +
+
+
+

This is a chat message.

+
+
+ COOKED + end + + it "renders the channel name if provided with multiQuote" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" multiQuote=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+ Originally sent in Cool Cats Club +
+
+
+
+ martin
+
+ +
+
+
+

This is a chat message.

+
+
+ COOKED + end + + it "renders the channel name if provided without multiQuote" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+
+
+ martin
+
+ +
+ + #Cool Cats Club +
+
+

This is a chat message.

+
+
+ COOKED + end + + it "renders with the chained attribute for more compact quotes" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" chained=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+
+
+ martin
+
+ +
+ + #Cool Cats Club +
+
+

This is a chat message.

+
+
+ COOKED + end + + it "renders with the noLink attribute to remove the links to the individual messages from the datetimes" do + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" multiQuote=\"true\" noLink=\"true\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+ Originally sent in Cool Cats Club +
+
+
+
+ martin
+
+ +
+
+
+

This is a chat message.

+
+
+ COOKED + end + + it "renders with the reactions attribute" do + reactions_attr = "+1:martin;heart:martin,eviltrout" + post.update!( + raw: + "[chat quote=\"martin;2321;2022-01-25T05:40:39Z\" channel=\"Cool Cats Club\" channelId=\"1234\" reactions=\"#{reactions_attr}\"]\nThis is a chat message.\n[/chat]", + ) + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +
+
+
+
+ martin
+
+ +
+ + #Cool Cats Club +
+
+

This is a chat message.

+
+
+ +1 1
+
+ heart 2
+
+
+
+ COOKED + end + + it "correctly renders inline and non-inline oneboxes combined with chat quotes" do + full_onebox_html = <<~HTML.chomp + + HTML + SiteSetting.enable_inline_onebox_on_all_domains = true + Oneboxer + .stubs(:cached_onebox) + .with("https://en.wikipedia.org/wiki/Hyperlink") + .returns(full_onebox_html) + stub_request(:get, "https://en.wikipedia.org/wiki/Hyperlink").to_return( + status: 200, + body: "Hyperlink - Wikipedia", + ) + + post.update!(raw: <<~MD) +https://en.wikipedia.org/wiki/Hyperlink + +[chat quote=\"martin;2321;2022-01-25T05:40:39Z\"] +This is a chat message. +[/chat] + +https://en.wikipedia.org/wiki/Hyperlink + +This is an inline onebox https://en.wikipedia.org/wiki/Hyperlink. + MD + + expect(post.cooked.chomp).to eq(<<~COOKED.chomp) +#{full_onebox_html} +
+
+
+
+martin
+
+ +
+
+
+

This is a chat message.

+
+
+#{full_onebox_html} +

This is an inline onebox https://en.wikipedia.org/wiki/Hyperlink.

+ COOKED + ensure + InlineOneboxer.invalidate("https://en.wikipedia.org/wiki/Hyperlink") + end +end diff --git a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb b/plugins/chat/spec/jobs/chat_channel_archive_spec.rb new file mode 100644 index 00000000000..a60c8de55d2 --- /dev/null +++ b/plugins/chat/spec/jobs/chat_channel_archive_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ChatChannelArchive do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, admin: true) } + fab!(:category) { Fabricate(:category) } + fab!(:chat_archive) do + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: user, + destination_topic_title: "This will be the archive topic", + destination_category_id: category.id, + total_messages: 10, + ) + end + + before { 10.times { Fabricate(:chat_message, chat_channel: chat_channel) } } + + def run_job + described_class.new.execute(chat_channel_archive_id: chat_archive.id) + end + + it "does nothing if the archive is already complete" do + chat_channel.chat_messages.destroy_all + chat_archive.update!(archived_messages: 10) + expect { run_job }.not_to change { Topic.count } + end + + it "does nothing if the archive does not exist" do + chat_archive.destroy + expect { run_job }.not_to change { Topic.count } + end + + it "processes the archive" do + Chat::ChatChannelArchiveService.any_instance.expects(:execute) + run_job + end +end diff --git a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb b/plugins/chat/spec/jobs/chat_channel_delete_spec.rb new file mode 100644 index 00000000000..1922fbd5f60 --- /dev/null +++ b/plugins/chat/spec/jobs/chat_channel_delete_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +describe Jobs::ChatChannelDelete do + fab!(:chat_channel) { Fabricate(:chat_channel) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + let(:users) { [user1, user2, user3] } + + before do + messages = [] + 20.times do + messages << Fabricate(:chat_message, chat_channel: chat_channel, user: users.sample) + end + @message_ids = messages.map(&:id) + + 10.times { ChatMessageReaction.create(chat_message: messages.sample, user: users.sample) } + + 10.times do + ChatUpload.create( + upload: Fabricate(:upload, user: users.sample), + chat_message: messages.sample, + ) + end + + ChatMention.create( + user: user2, + chat_message: messages.sample, + notification: Fabricate(:notification), + ) + + @incoming_chat_webhook_id = Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) + ChatWebhookEvent.create( + incoming_chat_webhook: @incoming_chat_webhook_id, + chat_message: messages.sample, + ) + + revision_message = messages.sample + ChatMessageRevision.create( + chat_message: revision_message, + old_message: "some old message", + new_message: revision_message.message, + ) + + ChatDraft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") + + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user1) + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user2) + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user3) + + chat_channel.trash! + end + + it "deletes all of the messages and related records completely" do + expect { described_class.new.execute(chat_channel_id: chat_channel.id) }.to change { + IncomingChatWebhook.where(chat_channel_id: chat_channel.id).count + }.by(-1).and change { + ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count + }.by(-1).and change { ChatDraft.where(chat_channel: chat_channel).count }.by( + -1, + ).and change { + UserChatChannelMembership.where(chat_channel: chat_channel).count + }.by(-3).and change { + ChatMessageRevision.where(chat_message_id: @message_ids).count + }.by(-1).and change { + ChatMention.where(chat_message_id: @message_ids).count + }.by(-1).and change { + ChatUpload.where(chat_message_id: @message_ids).count + }.by(-10).and change { + ChatMessage.where(id: @message_ids).count + }.by(-20).and change { + ChatMessageReaction.where( + chat_message_id: @message_ids, + ).count + }.by(-10) + end +end diff --git a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb b/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb new file mode 100644 index 00000000000..07f7f31604f --- /dev/null +++ b/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::DeleteOldChatMessages do + base_date = DateTime.parse("2020-12-01 00:00 UTC") + + fab!(:public_channel) { Fabricate(:category_channel) } + fab!(:public_days_old_0) do + Fabricate(:chat_message, chat_channel: public_channel, message: "hi", created_at: base_date) + end + fab!(:public_days_old_10) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 10.days - 1.second, + ) + end + fab!(:public_days_old_20) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 20.days - 1.second, + ) + end + fab!(:public_days_old_30) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + fab!(:public_trashed_days_old_30) do + Fabricate( + :chat_message, + chat_channel: public_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + + fab!(:dm_channel) do + Fabricate( + :chat_channel, + chatable: Fabricate(:direct_message_channel, users: [Fabricate(:user)]), + ) + end + fab!(:dm_days_old_0) do + Fabricate(:chat_message, chat_channel: dm_channel, message: "hi", created_at: base_date) + end + fab!(:dm_days_old_10) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 10.days - 1.second, + ) + end + fab!(:dm_days_old_20) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 20.days - 1.second, + ) + end + fab!(:dm_days_old_30) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + fab!(:dm_trashed_days_old_30) do + Fabricate( + :chat_message, + chat_channel: dm_channel, + message: "hi", + created_at: base_date - 30.days - 1.second, + ) + end + + before { freeze_time(base_date) } + + it "doesn't delete messages when settings are 0" do + SiteSetting.chat_channel_retention_days = 0 + SiteSetting.chat_dm_retention_days = 0 + + expect { described_class.new.execute }.not_to change { ChatMessage.count } + end + + describe "public channels" do + it "deletes public messages correctly" do + SiteSetting.chat_channel_retention_days = 20 + described_class.new.execute + expect(public_days_old_0.deleted_at).to be_nil + expect(public_days_old_10.deleted_at).to be_nil + expect { public_days_old_20 }.to raise_exception(ActiveRecord::RecordNotFound) + expect { public_days_old_30 }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "deletes trashed messages correctly" do + SiteSetting.chat_channel_retention_days = 20 + public_trashed_days_old_30.trash! + described_class.new.execute + expect { public_trashed_days_old_30.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "does nothing when no messages fall in the time range" do + SiteSetting.chat_channel_retention_days = 800 + expect { described_class.new.execute }.not_to change { ChatMessage.in_public_channel.count } + end + + it "resets last_read_message_id from memberships" do + SiteSetting.chat_channel_retention_days = 20 + membership = + UserChatChannelMembership.create!( + user: Fabricate(:user), + chat_channel: public_channel, + last_read_message_id: public_days_old_30.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + described_class.new.execute + + expect(membership.reload.last_read_message_id).to be_nil + end + + it "deletes flags associated to deleted chat messages" do + SiteSetting.chat_channel_retention_days = 10 + guardian = Guardian.new(Discourse.system_user) + Chat::ChatReviewQueue.new.flag_message( + public_days_old_20, + guardian, + ReviewableScore.types[:off_topic], + ) + + reviewable = ReviewableChatMessage.last + expect(reviewable).to be_present + + described_class.new.execute + + expect { public_days_old_20.reload }.to raise_exception(ActiveRecord::RecordNotFound) + expect { reviewable.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + end + + describe "dm channels" do + it "deletes public messages correctly" do + SiteSetting.chat_dm_retention_days = 20 + described_class.new.execute + expect(dm_days_old_0.deleted_at).to be_nil + expect(dm_days_old_10.deleted_at).to be_nil + expect { dm_days_old_20 }.to raise_exception(ActiveRecord::RecordNotFound) + expect { dm_days_old_30 }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "deletes trashed messages correctly" do + SiteSetting.chat_dm_retention_days = 20 + dm_trashed_days_old_30.trash! + described_class.new.execute + expect { dm_trashed_days_old_30.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + it "does nothing when no messages fall in the time range" do + SiteSetting.chat_dm_retention_days = 800 + expect { described_class.new.execute }.not_to change { ChatMessage.in_dm_channel.count } + end + + it "resets last_read_message_id from memberships" do + SiteSetting.chat_dm_retention_days = 20 + membership = + UserChatChannelMembership.create!( + user: Fabricate(:user), + chat_channel: dm_channel, + last_read_message_id: dm_days_old_30.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + described_class.new.execute + + expect(membership.reload.last_read_message_id).to be_nil + end + end +end diff --git a/plugins/chat/spec/jobs/process_chat_message_spec.rb b/plugins/chat/spec/jobs/process_chat_message_spec.rb new file mode 100644 index 00000000000..cb98c286afc --- /dev/null +++ b/plugins/chat/spec/jobs/process_chat_message_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ProcessChatMessage do + fab!(:chat_message) { Fabricate(:chat_message, message: "https://discourse.org/team") } + + it "updates cooked with oneboxes" do + stub_request(:get, "https://discourse.org/team").to_return( + status: 200, + body: "a", + ) + + stub_request(:head, "https://discourse.org/team").to_return(status: 200) + + described_class.new.execute(chat_message_id: chat_message.id) + expect(chat_message.reload.cooked).to eq( + "

https://discourse.org/team

", + ) + end + + context "when is_dirty args is true" do + fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } + + it "publishes the update" do + ChatPublisher.expects(:publish_processed!).once + described_class.new.execute(chat_message_id: chat_message.id, is_dirty: true) + end + end + + context "when is_dirty args is not true" do + fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } + + it "doesn’t publish the update" do + ChatPublisher.expects(:publish_processed!).never + described_class.new.execute(chat_message_id: chat_message.id) + end + + context "when the cooked message changed" do + it "publishes the update" do + chat_message.update!(cooked: "another lovely cat") + ChatPublisher.expects(:publish_processed!).once + described_class.new.execute(chat_message_id: chat_message.id) + end + end + end + + it "does not error when message is deleted" do + chat_message.destroy + expect { described_class.new.execute(chat_message_id: chat_message.id) }.not_to raise_exception + end +end diff --git a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb new file mode 100644 index 00000000000..4209caa940e --- /dev/null +++ b/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoJoinChannelBatch do + describe "#execute" do + fab!(:category) { Fabricate(:category) } + let!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: category) } + + it "joins all valid users in the batch" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "doesn't join users outside the batch" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, another_user) + end + + it "doesn't join suspended users" do + user.update!(suspended_till: 1.year.from_now) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "doesn't join users last_seen more than 3 months ago" do + user.update!(last_seen_at: 4.months.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "joins users with last_seen set to null" do + user.update!(last_seen_at: nil) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "does nothing if the channel is invalid" do + subject.execute(chat_channel_id: -1, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "does nothing if the channel chatable is not a category" do + dm_channel = Fabricate(:direct_message_channel) + channel.update!(chatable: dm_channel) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "enqueues the user count update job and marks the channel user count as stale" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel.id }) + + expect(channel.reload.user_count_stale).to eq(true) + end + + it "does not enqueue the user count update job or mark the channel user count as stale when there is more than use user" do + user_2 = Fabricate(:user) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: channel.id, + }, + ) { subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) } + + expect(channel.reload.user_count_stale).to eq(false) + end + + it "ignores users without chat_enabled" do + user.user_option.update!(chat_enabled: false) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_user_skipped(channel, user) + end + + it "sets the join reason to automatic" do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(new_membership.automatic?).to eq(true) + end + + it "skips anonymous users" do + user_2 = Fabricate(:anonymous) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "skips non-active users" do + user_2 = Fabricate(:user, active: false, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "skips staged users" do + user_2 = Fabricate(:user, staged: true, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, user_2) + end + + it "adds every user in the batch" do + user_2 = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user_2.id) + + assert_users_follows_channel(channel, [user, user_2]) + end + + it "publishes a message only to joined users" do + messages = + MessageBus.track_publish("/chat/new-channel") do + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + end + + expect(messages.size).to eq(1) + expect(messages.first.data.dig(:chat_channel, :id)).to eq(channel.id) + end + + describe "context when the channel's category is read restricted" do + fab!(:chatters_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: chatters_group) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: private_category) } + + before { chatters_group.add(user) } + + it "only joins group members with access to the category" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: another_user.id) + + assert_users_follows_channel(channel, [user]) + assert_user_skipped(channel, another_user) + end + + it "works if the user has access through more than one group" do + second_chatters_group = Fabricate(:group) + Fabricate(:category_group, category: category, group: second_chatters_group) + second_chatters_group.add(user) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) + + assert_users_follows_channel(channel, [user]) + end + + it "joins every user with access to the category" do + another_user = Fabricate(:user, last_seen_at: 15.minutes.ago) + chatters_group.add(another_user) + + subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: another_user.id) + + assert_users_follows_channel(channel, [user, another_user]) + end + end + end + + def assert_users_follows_channel(channel, users) + new_memberships = UserChatChannelMembership.where(user: users, chat_channel: channel) + expect(new_memberships.all?(&:following)).to eq(true) + end + + def assert_user_skipped(channel, user) + new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(new_membership).to be_nil + end +end diff --git a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb b/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb new file mode 100644 index 00000000000..cab7596d013 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoManageChannelMemberships do + let(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let(:category) { Fabricate(:category, user: user) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: category) } + + describe "queues batches to automatically add users to a channel" do + it "queues a batch for users with channel access" do + assert_batches_enqueued(channel, 1) + end + + it "does nothing when the channel doesn't exist" do + assert_batches_enqueued(ChatChannel.new(id: -1), 0) + end + + it "does nothing when the chatable is not a category" do + dm_channel = Fabricate(:direct_message_channel) + channel.update!(chatable: dm_channel) + + assert_batches_enqueued(channel, 0) + end + + it "excludes users not seen in the last 3 months" do + user.update!(last_seen_at: 3.months.ago) + + assert_batches_enqueued(channel, 0) + end + + it "excludes users without chat enabled" do + user.user_option.update!(chat_enabled: false) + + assert_batches_enqueued(channel, 0) + end + + it "respects the max_chat_auto_joined_users setting" do + SiteSetting.max_chat_auto_joined_users = 0 + + assert_batches_enqueued(channel, 0) + end + + it "does nothing when we already reached the max_chat_auto_joined_users limit" do + SiteSetting.max_chat_auto_joined_users = 1 + user_2 = Fabricate(:user, last_seen_at: 2.minutes.ago) + UserChatChannelMembership.create!( + user: user_2, + chat_channel: channel, + following: true, + join_mode: UserChatChannelMembership.join_modes[:automatic], + ) + + assert_batches_enqueued(channel, 0) + end + + it "ignores users that are already channel members" do + UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) + + assert_batches_enqueued(channel, 0) + end + + it "doesn't queue a batch when the user doesn't follow the channel" do + UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) + + assert_batches_enqueued(channel, 0) + end + + it "skips non-active users" do + user.update!(active: false) + + assert_batches_enqueued(channel, 0) + end + + it "skips suspended users" do + user.update!(suspended_till: 3.years.from_now) + + assert_batches_enqueued(channel, 0) + end + + it "skips staged users" do + user.update!(staged: true) + + assert_batches_enqueued(channel, 0) + end + + context "when the category has read restricted access" do + fab!(:chatters_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: chatters_group) } + let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: private_category) } + + it "doesn't queue a batch if the user is not a group member" do + assert_batches_enqueued(channel, 0) + end + + context "when the user has category access to a group" do + before { chatters_group.add(user) } + + it "queues a batch" do + assert_batches_enqueued(channel, 1) + end + end + end + + context "when chatable doesn’t exist anymore" do + before do + channel.chatable.destroy! + channel.reload + end + + it "does nothing" do + assert_batches_enqueued(channel, 0) + end + end + end + + def assert_batches_enqueued(channel, expected) + expect { subject.execute(chat_channel_id: channel.id) }.to change( + Jobs::AutoJoinChannelBatch.jobs, + :size, + ).by(expected) + end +end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb b/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb new file mode 100644 index 00000000000..3463d0a5068 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::ChatNotifyMentioned do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:public_channel) { Fabricate(:category_channel) } + + before do + Group.refresh_automatic_groups! + user_1.reload + user_2.reload + + @chat_group = Fabricate(:group, users: [user_1, user_2]) + @personal_chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: user_1, target_users: [user_1, user_2]) + + [user_1, user_2].each do |u| + Fabricate(:user_chat_channel_membership, chat_channel: public_channel, user: u) + end + end + + def create_chat_message(channel: public_channel, user: user_1) + Fabricate(:chat_message, chat_channel: channel, user: user, created_at: 10.minutes.ago) + end + + def track_desktop_notification( + user: user_2, + message:, + to_notify_ids_map:, + already_notified_user_ids: [] + ) + MessageBus + .track_publish("/chat/notification-alert/#{user.id}") do + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + already_notified_user_ids: already_notified_user_ids, + ) + end + .first + end + + def track_core_notification(user: user_2, message:, to_notify_ids_map:) + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + + Notification.where(user: user, notification_type: Notification.types[:chat_mention]).last + end + + describe "scenarios where we should skip sending notifications" do + let(:to_notify_ids_map) { { here_mentions: [user_2.id] } } + + it "does nothing if there is a newer version of the message" do + message = create_chat_message + ChatMessageRevision.create!(chat_message: message, old_message: "a", new_message: "b") + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing when user is not following the channel" do + message = create_chat_message + + UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( + following: false, + ) + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing when user doesn't have a membership record" do + message = create_chat_message + + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing if user is included in the already_notified_user_ids" do + message = create_chat_message + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification( + message: message, + to_notify_ids_map: to_notify_ids_map, + already_notified_user_ids: [user_2.id], + ) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_2, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "does nothing if user is not participating in a private channel" do + user_3 = Fabricate(:user) + @chat_group.add(user_3) + to_notify_map = { direct_mentions: [user_3.id] } + + message = create_chat_message(channel: @personal_chat_channel) + + PostAlerter.expects(:push_notification).never + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_map) + expect(desktop_notification).to be_nil + + created_notification = + Notification.where(user: user_3, notification_type: Notification.types[:chat_mention]).last + expect(created_notification).to be_nil + end + + it "skips desktop notifications based on user preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_nil + end + + it "skips push notifications based on user preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + PostAlerter.expects(:push_notification).never + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + + it "skips desktop notifications based on user muting preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + muted: true, + ) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_nil + end + + it "skips push notifications based on user muting preferences" do + message = create_chat_message + UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + muted: true, + ) + + PostAlerter.expects(:push_notification).never + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + end + + shared_examples "creates different notifications with basic data" do + let(:expected_channel_title) { public_channel.title(user_2) } + + it "works for desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification).to be_present + expect(desktop_notification.data[:notification_type]).to eq(Notification.types[:chat_mention]) + expect(desktop_notification.data[:username]).to eq(user_1.username) + expect(desktop_notification.data[:tag]).to eq( + Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + ) + expect(desktop_notification.data[:excerpt]).to eq(message.push_notification_excerpt) + expect(desktop_notification.data[:post_url]).to eq( + "/chat/channel/#{public_channel.id}/#{expected_channel_title}?messageId=#{message.id}", + ) + end + + it "works for push notifications" do + message = create_chat_message + + PostAlerter.expects(:push_notification).with( + user_2, + { + notification_type: Notification.types[:chat_mention], + username: user_1.username, + tag: Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + excerpt: message.push_notification_excerpt, + post_url: + "/chat/channel/#{public_channel.id}/#{expected_channel_title}?messageId=#{message.id}", + translated_title: payload_translated_title, + }, + ) + + subject.execute( + chat_message_id: message.id, + timestamp: message.created_at, + to_notify_ids_map: to_notify_ids_map, + ) + end + + it "works for core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(created_notification).to be_present + expect(created_notification.high_priority).to eq(true) + expect(created_notification.read).to eq(false) + + data_hash = created_notification.data_hash + + expect(data_hash[:chat_message_id]).to eq(message.id) + expect(data_hash[:chat_channel_id]).to eq(public_channel.id) + expect(data_hash[:mentioned_by_username]).to eq(user_1.username) + expect(data_hash[:is_direct_message_channel]).to eq(false) + expect(data_hash[:chat_channel_title]).to eq(expected_channel_title) + + chat_mention = + ChatMention.where(notification: created_notification, user: user_2, chat_message: message) + expect(chat_mention).to be_present + end + end + + describe "#execute" do + describe "global mention notifications" do + let(:to_notify_ids_map) { { global_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@all", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes global mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to eq("all") + end + + it "includes global mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@all", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "here mention notifications" do + let(:to_notify_ids_map) { { here_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@here", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to eq("here") + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@here", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "direct mention notifications" do + let(:to_notify_ids_map) { { direct_mentions: [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.direct", + username: user_1.username, + identifier: "", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to be_nil + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.direct", + username: user_1.username, + identifier: "", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + + describe "group mentions" do + let(:to_notify_ids_map) { { @chat_group.name.to_sym => [user_2.id] } } + + let(:payload_translated_title) do + I18n.t( + "discourse_push_notifications.popup.chat_mention.other_type", + username: user_1.username, + identifier: "@#{@chat_group.name}", + channel: public_channel.title(user_2), + ) + end + + include_examples "creates different notifications with basic data" + + it "includes here mention specific data to core notifications" do + message = create_chat_message + + created_notification = + track_core_notification(message: message, to_notify_ids_map: to_notify_ids_map) + data_hash = created_notification.data_hash + + expect(data_hash[:identifier]).to be_nil + expect(data_hash[:is_group_mention]).to eq(true) + end + + it "includes here mention specific data to desktop notifications" do + message = create_chat_message + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expect(desktop_notification.data[:translated_title]).to eq(payload_translated_title) + end + + context "with private channels" do + it "users a different translated title" do + message = create_chat_message(channel: @personal_chat_channel) + + desktop_notification = + track_desktop_notification(message: message, to_notify_ids_map: to_notify_ids_map) + + expected_title = + I18n.t( + "discourse_push_notifications.popup.direct_message_chat_mention.other_type", + username: user_1.username, + identifier: "@#{@chat_group.name}", + ) + + expect(desktop_notification.data[:translated_title]).to eq(expected_title) + end + end + end + end +end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb b/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb new file mode 100644 index 00000000000..b01beadcb20 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::ChatNotifyWatching do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:group) { Fabricate(:group) } + let(:except_user_ids) { [] } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + def run_job + described_class.new.execute(chat_message_id: message.id, except_user_ids: except_user_ids) + end + + def notification_messages_for(user) + MessageBus + .track_publish { run_job } + .filter { |m| m.channel == "/chat/notification-alert/#{user.id}" } + end + + context "for a category channel" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel) + end + fab!(:message) do + Fabricate(:chat_message, chat_channel: channel, user: user1, message: "this is a new message") + end + + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a desktop notification" do + messages = notification_messages_for(user2) + + expect(messages.first.data).to include( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: "/chat/channel/#{channel.id}/#{channel.title(user2)}", + excerpt: message.message, + }, + ) + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + + context "when mobile_notification_level is always and desktop_notification_level is none" do + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a mobile notification" do + PostAlerter.expects(:push_notification).with( + user2, + has_entries( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: "/chat/channel/#{channel.id}/#{channel.title(user2)}", + excerpt: message.message, + }, + ), + ) + messages = notification_messages_for(user2) + expect(messages.length).to be_zero + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + end + + context "when the target user cannot chat" do + before { SiteSetting.chat_allowed_groups = group.id } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user cannot see the chat channel" do + before { channel.update!(chatable: Fabricate(:private_category, group: group)) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user has seen the message already" do + before { membership2.update!(last_read_message_id: message.id) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is online via presence channel" do + before { PresenceChannel.any_instance.expects(:user_ids).returns([user2.id]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is suspended" do + before { user2.update!(suspended_till: 1.year.from_now) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is inside the except_user_ids array" do + let(:except_user_ids) { [user2.id] } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + end + + context "for a direct message channel" do + fab!(:channel) { Fabricate(:direct_message_chat_channel, users: [user1, user2, user3]) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel) + end + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: user1) } + + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a desktop notification" do + messages = notification_messages_for(user2) + + expect(messages.first.data).to include( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: "/chat/channel/#{channel.id}/#{channel.title(user2)}", + excerpt: message.message, + }, + ) + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + + context "when mobile_notification_level is always and desktop_notification_level is none" do + before do + membership2.update!( + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "sends a mobile notification" do + PostAlerter.expects(:push_notification).with( + user2, + has_entries( + { + username: user1.username, + notification_type: Notification.types[:chat_message], + post_url: "/chat/channel/#{channel.id}/#{channel.title(user2)}", + excerpt: message.message, + }, + ), + ) + messages = notification_messages_for(user2) + expect(messages.length).to be_zero + end + + context "when the channel is muted via membership preferences" do + before { membership2.update!(muted: true) } + + it "does not send a desktop or mobile notification" do + PostAlerter.expects(:push_notification).never + messages = notification_messages_for(user2) + expect(messages).to be_empty + end + end + end + + context "when the target user cannot chat" do + before { SiteSetting.chat_allowed_groups = group.id } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user cannot see the chat channel" do + before { membership2.destroy! } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user has seen the message already" do + before { membership2.update!(last_read_message_id: message.id) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is online via presence channel" do + before { PresenceChannel.any_instance.expects(:user_ids).returns([user2.id]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is suspended" do + before { user2.update!(suspended_till: 1.year.from_now) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is inside the except_user_ids array" do + let(:except_user_ids) { [user2.id] } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + + context "when the target user is preventing communication from the message creator" do + before { UserCommScreener.any_instance.expects(:allowing_actor_communication).returns([]) } + + it "does not send a desktop notification" do + expect(notification_messages_for(user2).count).to be_zero + end + end + end +end diff --git a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb b/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb new file mode 100644 index 00000000000..6674e53b9e7 --- /dev/null +++ b/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::UpdateChannelUserCount do + fab!(:channel) { Fabricate(:category_channel, user_count: 0, user_count_stale: true) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user2) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user3) + end + + it "does nothing if the channel does not exist" do + channel.destroy + ChatPublisher.expects(:publish_chat_channel_metadata).never + expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) + end + + it "does nothing if the user count has not been marked stale" do + channel.update!(user_count_stale: false) + ChatPublisher.expects(:publish_chat_channel_metadata).never + expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) + end + + it "updates the channel user_count and sets user_count_stale back to false" do + ChatPublisher.expects(:publish_chat_channel_metadata).with(channel) + described_class.new.execute(chat_channel_id: channel.id) + channel.reload + expect(channel.user_count).to eq(3) + expect(channel.user_count_stale).to eq(false) + end +end diff --git a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb new file mode 100644 index 00000000000..e420b0a8a58 --- /dev/null +++ b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::AutoJoinUsers do + it "works" do + Jobs.run_immediately! + channel = Fabricate(:category_channel, auto_join_users: true) + user = Fabricate(:user, last_seen_at: 1.minute.ago, active: true) + + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership).to be_nil + + subject.execute({}) + + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership.following).to eq(true) + end +end diff --git a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb b/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb new file mode 100644 index 00000000000..0741a655c35 --- /dev/null +++ b/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe Jobs::EmailChatNotifications do + before { Jobs.run_immediately! } + + context "when chat is enabled" do + before { SiteSetting.chat_enabled = true } + + it "starts the mailer" do + Chat::ChatMailer.expects(:send_unread_mentions_summary) + + Jobs.enqueue(:email_chat_notifications) + end + end + + context "when chat is not enabled" do + it "does nothing" do + Chat::ChatMailer.expects(:send_unread_mentions_summary).never + + Jobs.enqueue(:email_chat_notifications) + end + end +end diff --git a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb b/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb new file mode 100644 index 00000000000..753b7b7bdfa --- /dev/null +++ b/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::UpdateUserCountsForChatChannels do + fab!(:chat_channel_1) { Fabricate(:category_channel, user_count: 0) } + fab!(:chat_channel_2) { Fabricate(:category_channel, user_count: 0) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + fab!(:user_4) { Fabricate(:user) } + + def create_memberships + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: false) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + end + + it "sets the user_count correctly for each chat channel" do + create_memberships + + Jobs::UpdateUserCountsForChatChannels.new.execute + + expect(chat_channel_1.reload.user_count).to eq(2) + expect(chat_channel_2.reload.user_count).to eq(3) + end + + it "does not count suspended, non-activated, nor staged users" do + user_1.user_chat_channel_memberships.create!(chat_channel: chat_channel_1, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel_2, following: true) + user_2.update(suspended_till: 3.weeks.from_now) + user_3.update(staged: true) + user_4.update(active: false) + + Jobs::UpdateUserCountsForChatChannels.new.execute + + expect(chat_channel_1.reload.user_count).to eq(1) + expect(chat_channel_2.reload.user_count).to eq(0) + end + + it "does not count archived, or read_only channels" do + create_memberships + + chat_channel_1.update!(status: :archived) + Jobs::UpdateUserCountsForChatChannels.new.execute + expect(chat_channel_1.reload.user_count).to eq(0) + + chat_channel_1.update!(status: :read_only) + Jobs::UpdateUserCountsForChatChannels.new.execute + expect(chat_channel_1.reload.user_count).to eq(0) + end +end diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb new file mode 100644 index 00000000000..5c4c97c3926 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatChannelArchiveService do + class FakeArchiveError < StandardError + end + + fab!(:channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, admin: true) } + fab!(:category) { Fabricate(:category) } + let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } + subject { Chat::ChatChannelArchiveService } + + describe "#begin_archive_process" do + before { 3.times { Fabricate(:chat_message, chat_channel: channel) } } + + it "marks the channel as read_only" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect(channel.reload.status).to eq("read_only") + end + + it "creates the chat channel archive record to save progress and topic params" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect(channel_archive.archived_by).to eq(user) + expect(channel_archive.destination_topic_title).to eq("This will be a new topic") + expect(channel_archive.destination_category_id).to eq(category.id) + expect(channel_archive.total_messages).to eq(3) + expect(channel_archive.archived_messages).to eq(0) + end + + it "enqueues the archive job" do + channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + end + + it "does nothing if there is already an archive record for the channel" do + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect { + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + }.not_to change { ChatChannelArchive.count } + end + + it "does not count already deleted messages toward the archive total" do + new_message = Fabricate(:chat_message, chat_channel: channel) + new_message.trash! + channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + expect(channel_archive.total_messages).to eq(3) + end + end + + describe "#execute" do + def create_messages(num) + num.times { Fabricate(:chat_message, chat_channel: channel) } + end + + def start_archive + @channel_archive = + subject.begin_archive_process( + chat_channel: channel, + acting_user: user, + topic_params: topic_params, + ) + end + + context "when archiving to a new topic" do + let(:topic_params) do + { topic_title: "This will be a new topic", category_id: category.id, tags: %w[news gossip] } + end + + it "makes a topic, deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do + create_messages(50) && start_archive + reaction_message = ChatMessage.last + ChatMessageReaction.create!( + chat_message: reaction_message, + user: Fabricate(:user), + emoji: "+1", + ) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.destination_topic.title).to eq("This will be a new topic") + expect(@channel_archive.destination_topic.category).to eq(category) + expect(@channel_archive.destination_topic.user).to eq(Discourse.system_user) + expect(@channel_archive.destination_topic.tags.map(&:name)).to match_array(%w[news gossip]) + + topic = @channel_archive.destination_topic + expect(topic.posts.count).to eq(11) + topic + .posts + .where.not(post_number: 1) + .each do |post| + expect(post.raw).to include("[chat") + expect(post.raw).to include("noLink=\"true\"") + expect(post.user).to eq(Discourse.system_user) + + if post.raw.include?(";#{reaction_message.id};") + expect(post.raw).to include("reactions=") + end + end + expect(topic.archived).to eq(true) + + expect(@channel_archive.archived_messages).to eq(50) + expect(@channel_archive.chat_channel.status).to eq("archived") + expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) + end + + it "does not stop the process if the post length is too high (validations disabled)" do + create_messages(50) && start_archive + SiteSetting.max_post_length = 1 + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + end + + it "successfully links uploads from messages to the post" do + create_messages(3) && start_archive + ChatUpload.create(chat_message: ChatMessage.last, upload: Fabricate(:upload)) + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect(@channel_archive.destination_topic.posts.last.upload_references.count).to eq(1) + end + + it "successfully sends a private message to the archiving user" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + pm_topic = Topic.private_messages.last + expect(pm_topic.topic_allowed_users.first.user).to eq(@channel_archive.archived_by) + expect(pm_topic.title).to eq( + I18n.t("system_messages.chat_channel_archive_complete.subject_template"), + ) + end + + describe "channel members" do + before do + create_messages(3) + channel + .chat_messages + .map(&:user) + .each do |user| + UserChatChannelMembership.create!(chat_channel: channel, user: user, following: true) + end + end + + it "unfollows (leaves) the channel for all users" do + expect( + UserChatChannelMembership.where(chat_channel: channel, following: true).count, + ).to eq(3) + start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect( + UserChatChannelMembership.where(chat_channel: channel, following: true).count, + ).to eq(0) + end + + it "resets unread state for all users" do + UserChatChannelMembership.last.update!( + last_read_message_id: channel.chat_messages.first.id, + ) + start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + expect(UserChatChannelMembership.last.last_read_message_id).to eq( + channel.chat_messages.last.id, + ) + end + end + + describe "chat_archive_destination_topic_status setting" do + context "when set to archived" do + before { SiteSetting.chat_archive_destination_topic_status = "archived" } + + it "archives the topic" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(true) + end + end + + context "when set to open" do + before { SiteSetting.chat_archive_destination_topic_status = "open" } + + it "leaves the topic open" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.open?).to eq(true) + end + end + + context "when set to closed" do + before { SiteSetting.chat_archive_destination_topic_status = "closed" } + + it "closes the topic" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.closed?).to eq(true) + end + end + + context "when archiving to an existing topic" do + it "does not change the status of the topic" do + create_messages(3) && start_archive + @channel_archive.update( + destination_topic_title: nil, + destination_topic_id: Fabricate(:topic).id, + ) + subject.new(@channel_archive).execute + topic = @channel_archive.destination_topic + topic.reload + expect(topic.archived).to eq(false) + expect(topic.closed?).to eq(false) + end + end + end + end + + context "when archiving to an existing topic" do + fab!(:topic) { Fabricate(:topic) } + let(:topic_params) { { topic_id: topic.id } } + + before { 3.times { Fabricate(:post, topic: topic) } } + + it "deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do + create_messages(50) && start_archive + reaction_message = ChatMessage.last + ChatMessageReaction.create!( + chat_message: reaction_message, + user: Fabricate(:user), + emoji: "+1", + ) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.destination_topic.title).to eq(topic.title) + expect(@channel_archive.destination_topic.category).to eq(topic.category) + expect(@channel_archive.destination_topic.user).to eq(topic.user) + + topic = @channel_archive.destination_topic + + # existing posts + 10 archive posts + expect(topic.posts.count).to eq(13) + topic + .posts + .where.not(post_number: [1, 2, 3]) + .each do |post| + expect(post.raw).to include("[chat") + expect(post.raw).to include("noLink=\"true\"") + expect(post.user).to eq(Discourse.system_user) + + if post.raw.include?(";#{reaction_message.id};") + expect(post.raw).to include("reactions=") + end + end + expect(topic.archived).to eq(false) + + expect(@channel_archive.archived_messages).to eq(50) + expect(@channel_archive.chat_channel.status).to eq("archived") + expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) + end + + it "handles errors gracefully, sends a private message to the archiving user, and is idempotent on retry" do + Rails.logger = @fake_logger = FakeLogger.new + create_messages(35) && start_archive + + Chat::ChatChannelArchiveService + .any_instance + .stubs(:create_post) + .raises(FakeArchiveError.new("this is a test error")) + + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + expect { subject.new(@channel_archive).execute }.to raise_error(FakeArchiveError) + end + + expect(@channel_archive.reload.archive_error).to eq("this is a test error") + + pm_topic = Topic.private_messages.last + expect(pm_topic.topic_allowed_users.first.user).to eq(@channel_archive.archived_by) + expect(pm_topic.title).to eq( + I18n.t("system_messages.chat_channel_archive_failed.subject_template"), + ) + + Chat::ChatChannelArchiveService.any_instance.unstub(:create_post) + stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + subject.new(@channel_archive).execute + end + + @channel_archive.reload + expect(@channel_archive.archive_error).to eq(nil) + expect(@channel_archive.archived_messages).to eq(35) + expect(@channel_archive.complete?).to eq(true) + # existing posts + 7 archive posts + expect(topic.posts.count).to eq(10) + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb new file mode 100644 index 00000000000..cfa1b048724 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +describe Chat::ChatChannelFetcher do + fab!(:category) { Fabricate(:category, name: "support") } + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:category_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_channel1) { Fabricate(:direct_message_channel) } + fab!(:dm_channel2) { Fabricate(:direct_message_channel) } + fab!(:direct_message_channel1) { Fabricate(:dm_channel, chatable: dm_channel1) } + fab!(:direct_message_channel2) { Fabricate(:dm_channel, chatable: dm_channel2) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + def guardian + Guardian.new(user1) + end + + def memberships + UserChatChannelMembership.where(user: user1) + end + + describe ".structured" do + it "returns open channel only" do + category_channel.user_chat_channel_memberships.create!(user: user1, following: true) + + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to contain_exactly(category_channel) + + category_channel.closed!(Discourse.system_user) + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to be_blank + end + + it "returns followed channel only" do + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to be_blank + + category_channel.user_chat_channel_memberships.create!(user: user1, following: true) + channels = subject.structured(guardian)[:public_channels] + + expect(channels).to contain_exactly(category_channel) + end + end + + describe ".unread_counts" do + context "when user is member of the channel" do + before do + Fabricate(:user_chat_channel_membership, chat_channel: category_channel, user: user1) + end + + context "with unread messages" do + before do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + Fabricate(:chat_message, chat_channel: category_channel, message: "bonjour", user: user2) + end + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(2) + end + end + + context "with no unread messages" do + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + + context "when last unread message has been deleted" do + fab!(:last_unread) do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + end + + before { last_unread.update!(deleted_at: Time.zone.now) } + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + end + + context "when user is not member of the channel" do + context "when the channel has new messages" do + before do + Fabricate(:chat_message, chat_channel: category_channel, message: "hi", user: user2) + end + + it "returns the correct count" do + unread_counts = subject.unread_counts([category_channel], user1) + expect(unread_counts[category_channel.id]).to eq(0) + end + end + end + end + + describe ".all_secured_channel_ids" do + it "returns nothing by default if the user has no memberships" do + expect(subject.all_secured_channel_ids(guardian)).to eq([]) + end + + context "when the user has memberships to all the channels" do + before do + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + UserChatChannelMembership.create!( + user: user1, + chat_channel: direct_message_channel1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "returns category channel because they are public by default" do + expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + end + + it "returns all the channels if the user is a member of the DM channel also" do + DirectMessageUser.create!(user: user1, direct_message_channel: dm_channel1) + expect(subject.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id, direct_message_channel1.id], + ) + end + + it "does not include the category channel if the category is a private category the user cannot see" do + category_channel.update!(chatable: private_category) + expect(subject.all_secured_channel_ids(guardian)).to be_empty + GroupUser.create!(group: private_category.groups.last, user: user1) + expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + end + end + end + + describe "#secured_public_channels" do + let(:following) { false } + + it "does not include DM channels" do + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by channel name, or category name" do + expect( + subject.secured_public_channels( + guardian, + memberships, + following: following, + filter: "support", + ).map(&:id), + ).to match_array([category_channel.id]) + + category_channel.update!(name: "cool stuff") + + expect( + subject.secured_public_channels( + guardian, + memberships, + following: following, + filter: "cool stuff", + ).map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by status" do + expect( + subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + ).to match_array([]) + + category_channel.closed!(Discourse.system_user) + + expect( + subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + ).to match_array([category_channel.id]) + end + + it "can filter by following" do + expect( + subject.secured_public_channels(guardian, memberships, following: true).map(&:id), + ).to be_blank + end + + it "can filter by not following" do + category_channel.user_chat_channel_memberships.create!(user: user1, following: false) + another_channel = Fabricate(:category_channel) + + expect( + subject.secured_public_channels(guardian, memberships, following: false).map(&:id), + ).to match_array([category_channel.id, another_channel.id]) + end + + it "ensures offset is >= 0" do + expect( + subject.secured_public_channels(guardian, memberships, offset: -235).map(&:id), + ).to match_array([category_channel.id]) + end + + it "ensures limit is > 0" do + expect( + subject.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map(&:id), + ).to match_array([category_channel.id]) + end + + it "ensures limit has a max value" do + over_limit = Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 + over_limit.times { Fabricate(:category_channel) } + + expect( + subject.secured_public_channels(guardian, memberships, limit: over_limit).length, + ).to eq(Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) + end + + it "does not show the user category channels they cannot access" do + category_channel.update!(chatable: private_category) + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to be_empty + end + + context "when scoping to the user's channel memberships" do + let(:following) { true } + + it "only returns channels where the user is a member and is following the channel" do + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to be_empty + + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + + expect( + subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + ).to match_array([category_channel.id]) + end + + it "includes the unread count based on mute settings" do + UserChatChannelMembership.create!( + user: user1, + chat_channel: category_channel, + following: true, + ) + + Fabricate(:chat_message, user: user2, chat_channel: category_channel) + Fabricate(:chat_message, user: user2, chat_channel: category_channel) + + resolved_memberships = memberships + subject.secured_public_channels(guardian, resolved_memberships, following: following) + + expect( + resolved_memberships + .find { |membership| membership.chat_channel_id == category_channel.id } + .unread_count, + ).to eq(2) + + resolved_memberships.last.update!(muted: true) + + resolved_memberships = memberships + subject.secured_public_channels(guardian, resolved_memberships, following: following) + + expect( + resolved_memberships + .find { |membership| membership.chat_channel_id == category_channel.id } + .unread_count, + ).to eq(0) + end + end + end + + describe "#secured_direct_message_channels" do + it "includes direct message channels the user is a member of ordered by last_message_sent_at" do + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message_channel: dm_channel1, user: user1) + DirectMessageUser.create!(direct_message_channel: dm_channel1, user: user2) + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel2, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message_channel: dm_channel2, user: user1) + DirectMessageUser.create!(direct_message_channel: dm_channel2, user: user2) + + direct_message_channel1.update!(last_message_sent_at: 1.day.ago) + direct_message_channel2.update!(last_message_sent_at: 1.hour.ago) + + expect( + subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + ).to eq([direct_message_channel2.id, direct_message_channel1.id]) + end + + it "does not include direct message channels where the user is a member but not a direct_message_user" do + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message_channel: dm_channel1, user: user2) + + expect( + subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + ).not_to include(direct_message_channel1.id) + end + + it "includes the unread count based on mute settings for the user's channel membership" do + membership = + Fabricate( + :user_chat_channel_membership_for_dm, + chat_channel: direct_message_channel1, + user: user1, + following: true, + ) + DirectMessageUser.create!(direct_message_channel: dm_channel1, user: user1) + DirectMessageUser.create!(direct_message_channel: dm_channel1, user: user2) + + Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) + Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) + resolved_memberships = memberships + + subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + target_membership = + resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } + expect(target_membership.unread_count).to eq(2) + + resolved_memberships = memberships + target_membership = + resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } + target_membership.update!(muted: true) + subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + expect(target_membership.unread_count).to eq(0) + end + end + + describe ".find_with_access_check" do + it "raises NotFound if the channel does not exist" do + category_channel.destroy! + expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( + Discourse::NotFound, + ) + end + + it "raises InvalidAccess if the user cannot see the channel" do + category_channel.update!(chatable: private_category) + expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( + Discourse::InvalidAccess, + ) + end + + it "returns the chat channel if it is found and accessible" do + expect(subject.find_with_access_check(category_channel.id, guardian)).to eq(category_channel) + end + end +end diff --git a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb b/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb new file mode 100644 index 00000000000..f5c9c694792 --- /dev/null +++ b/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe Chat::ChatChannelMembershipManager do + fab!(:user) { Fabricate(:user) } + fab!(:channel1) { Fabricate(:category_channel) } + fab!(:channel2) { Fabricate(:category_channel) } + + describe ".find_for_user" do + let!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel1, following: true) + end + + it "returns nil if it cannot find a membership for the user and channel" do + expect(described_class.new(channel2).find_for_user(user)).to be_blank + end + + it "returns the membership for the channel and user" do + membership = described_class.new(channel1).find_for_user(user) + expect(membership.chat_channel_id).to eq(channel1.id) + expect(membership.user_id).to eq(user.id) + expect(membership.following).to eq(true) + end + + it "scopes by following and returns nil if it does not match the scope" do + membership.update!(following: false) + expect(described_class.new(channel1).find_for_user(user, following: true)).to be_blank + end + end + + describe ".follow" do + it "creates a membership if one does not exist for the user and channel already" do + membership = nil + expect { membership = described_class.new(channel1).follow(user) }.to change { + UserChatChannelMembership.count + }.by(1) + expect(membership.following).to eq(true) + expect(membership.chat_channel).to eq(channel1) + expect(membership.user).to eq(user) + end + + it "enqueues user_count recalculation and marks user_count_stale as true" do + described_class.new(channel1).follow(user) + expect(channel1.reload.user_count_stale).to eq(true) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + end + + it "updates the membership to following if it already existed" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: false, + ) + expect { membership = described_class.new(channel1).follow(user) }.not_to change { + UserChatChannelMembership.count + } + expect(membership.reload.following).to eq(true) + end + end + + describe ".unfollow" do + it "does nothing if the user is not following the channel" do + expect(described_class.new(channel2).unfollow(user)).to be_blank + end + + it "updates following for the membership to false and recalculates the user count" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: true, + ) + described_class.new(channel1).unfollow(user) + membership.reload + expect(membership.following).to eq(false) + expect(channel1.reload.user_count_stale).to eq(true) + expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + end + + it "does not recalculate user count if the user was already not following the channel" do + membership = + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: channel1, + following: false, + ) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: channel1.id, + }, + ) { described_class.new(channel1).unfollow(user) } + expect(channel1.reload.user_count_stale).to eq(false) + end + end +end diff --git a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb b/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb new file mode 100644 index 00000000000..716632c9e89 --- /dev/null +++ b/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessageBookmarkable do + fab!(:user) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user) } + fab!(:other_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:category_channel) { Fabricate(:category_channel, chatable: other_category) } + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:channel) { Fabricate(:category_channel) } + + before do + Bookmark.register_bookmarkable(ChatMessageBookmarkable) + UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + end + + let!(:message1) { Fabricate(:chat_message, chat_channel: channel) } + let!(:message2) { Fabricate(:chat_message, chat_channel: channel) } + let!(:bookmark1) do + Fabricate(:bookmark, user: user, bookmarkable: message1, name: "something i gotta do") + end + let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: message2) } + let!(:bookmark3) { Fabricate(:bookmark) } + + subject { RegisteredBookmarkable.new(ChatMessageBookmarkable) } + + describe "#perform_list_query" do + it "returns all the user's bookmarks" do + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for messages inside category chat channels the user cannot access" do + channel.update(chatable: other_category) + expect(subject.perform_list_query(user, guardian)).to eq(nil) + other_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for messages inside direct message chat channels the user cannot access" do + dm_channel = Fabricate(:direct_message_channel) + channel.update(chatable: dm_channel) + expect(subject.perform_list_query(user, guardian)).to eq(nil) + DirectMessageUser.create(user: user, direct_message_channel: dm_channel) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array( + [bookmark1.id, bookmark2.id], + ) + end + + it "does not return bookmarks for deleted messages" do + message1.trash! + guardian = Guardian.new(user) + expect(subject.perform_list_query(user, guardian).map(&:id)).to match_array([bookmark2.id]) + end + end + + describe "#perform_search_query" do + before { SearchIndexer.enable } + + it "returns bookmarks that match by name" do + ts_query = Search.ts_query(term: "gotta", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%gotta%", + ts_query, + ).map(&:id), + ).to match_array([bookmark1.id]) + end + + it "returns bookmarks that match by chat message message content" do + message2.update(message: "some good soup") + + ts_query = Search.ts_query(term: "good soup", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%good soup%", + ts_query, + ).map(&:id), + ).to match_array([bookmark2.id]) + + ts_query = Search.ts_query(term: "blah", ts_config: "simple") + expect( + subject.perform_search_query( + subject.perform_list_query(user, guardian), + "%blah%", + ts_query, + ).map(&:id), + ).to eq([]) + end + end + + describe "#can_send_reminder?" do + it "cannot send the reminder if the message or channel is deleted" do + expect(subject.can_send_reminder?(bookmark1)).to eq(true) + bookmark1.bookmarkable.trash! + bookmark1.reload + expect(subject.can_send_reminder?(bookmark1)).to eq(false) + ChatMessage.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! + bookmark1.reload + bookmark1.bookmarkable.chat_channel.trash! + bookmark1.reload + expect(subject.can_send_reminder?(bookmark1)).to eq(false) + end + end + + describe "#reminder_handler" do + it "creates a notification for the user with the correct details" do + expect { subject.send_reminder_notification(bookmark1) }.to change { Notification.count }.by( + 1, + ) + notification = user.notifications.last + expect(notification.notification_type).to eq(Notification.types[:bookmark_reminder]) + expect(notification.data).to eq( + { + title: + I18n.t( + "chat.bookmarkable.notification_title", + channel_name: bookmark1.bookmarkable.chat_channel.title(bookmark1.user), + ), + bookmarkable_url: bookmark1.bookmarkable.url, + display_username: bookmark1.user.username, + bookmark_name: bookmark1.name, + bookmark_id: bookmark1.id, + }.to_json, + ) + end + end + + describe "#can_see?" do + it "returns false if the chat message is in a channel the user cannot see" do + expect(subject.can_see?(guardian, bookmark1)).to eq(true) + bookmark1.bookmarkable.chat_channel.update!(chatable: private_category) + expect(subject.can_see?(guardian, bookmark1)).to eq(false) + private_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect(subject.can_see?(guardian, bookmark1)).to eq(true) + end + end + + describe "#validate_before_create" do + it "raises InvalidAccess if the user cannot see the chat channel" do + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + bookmark1.bookmarkable.chat_channel.update!(chatable: private_category) + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.to raise_error( + Discourse::InvalidAccess, + ) + private_category.groups.last.add(user) + bookmark1.reload + user.reload + guardian = Guardian.new(user) + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + end + + it "raises InvalidAccess if the chat message is deleted" do + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.not_to raise_error + bookmark1.bookmarkable.trash! + bookmark1.reload + expect { subject.validate_before_create(guardian, bookmark1.bookmarkable) }.to raise_error( + Discourse::InvalidAccess, + ) + end + end + + describe "#cleanup_deleted" do + it "deletes bookmarks for chat messages deleted more than 3 days ago" do + bookmark_post = Fabricate(:bookmark, bookmarkable: Fabricate(:post)) + bookmark1.bookmarkable.trash! + bookmark1.bookmarkable.update!(deleted_at: 4.days.ago) + subject.cleanup_deleted + expect(Bookmark.exists?(id: bookmark1.id)).to eq(false) + expect(Bookmark.exists?(id: bookmark2.id)).to eq(true) + expect(Bookmark.exists?(id: bookmark_post.id)).to eq(true) + end + end +end diff --git a/plugins/chat/spec/lib/chat_message_reactor_spec.rb b/plugins/chat/spec/lib/chat_message_reactor_spec.rb new file mode 100644 index 00000000000..ef158586378 --- /dev/null +++ b/plugins/chat/spec/lib/chat_message_reactor_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatMessageReactor do + fab!(:reacting_user) { Fabricate(:user) } + fab!(:channel) { Fabricate(:category_channel) } + fab!(:reactor) { described_class.new(reacting_user, channel) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel, user: reacting_user) } + let(:subject) { described_class.new(reacting_user, channel) } + + it "raises an error if the user cannot see the channel" do + channel.update!(chatable: Fabricate(:private_category, group: Group[:staff])) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + end + + it "raises an error if the user cannot react" do + SpamRule::AutoSilence.new(reacting_user).silence_user + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + end + + it "raises an error if the channel status is not open" do + channel.update!(status: ChatChannel.statuses[:archived]) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to raise_error(Discourse::InvalidAccess) + channel.update!(status: ChatChannel.statuses[:open]) + expect { + subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") + }.to change(ChatMessageReaction, :count).by(1) + end + + it "raises an error if the reaction is not valid" do + expect { + reactor.react!(message_id: message_1.id, react_action: :foo, emoji: ":+1:") + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error if the emoji does not exist" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":woohoo:") + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an error if the message is not found" do + expect { + reactor.react!(message_id: -999, react_action: :add, emoji: ":woohoo:") + }.to raise_error(Discourse::InvalidParameters) + end + + context "when max reactions has been reached" do + before do + emojis = Emoji.all.slice(0, Chat::ChatMessageReactor::MAX_REACTIONS_LIMIT) + emojis.each do |emoji| + ChatMessageReaction.create!( + chat_message: message_1, + user: reacting_user, + emoji: ":#{emoji.name}:", + ) + end + end + + it "adding a reaction raises an error" do + expect { + reactor.react!( + message_id: message_1.id, + react_action: :add, + emoji: ":#{Emoji.all.last.name}:", + ) + }.to raise_error(Discourse::InvalidAccess) + end + + it "removing a reaction works" do + expect { + reactor.react!( + message_id: message_1.id, + react_action: :add, + emoji: ":#{Emoji.all.first.name}:", + ) + }.to_not raise_error + end + end + + it "creates a membership when not present" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.to change(UserChatChannelMembership, :count).by(1) + end + + it "doesn’t create a membership when present" do + UserChatChannelMembership.create!(user: reacting_user, chat_channel: channel, following: true) + + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.not_to change(UserChatChannelMembership, :count) + end + + it "can add a reaction" do + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.to change(ChatMessageReaction, :count).by(1) + end + + it "doesn’t duplicate reactions" do + ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + + expect { + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + }.not_to change(ChatMessageReaction, :count) + end + + it "can remove an existing reaction" do + ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + + expect { + reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") + }.to change(ChatMessageReaction, :count).by(-1) + end + + it "does nothing when removing if no reaction found" do + expect { + reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") + }.not_to change(ChatMessageReaction, :count) + end + + it "publishes the reaction" do + ChatPublisher.expects(:publish_reaction!).once + + reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") + end +end diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat_notifier_spec.rb new file mode 100644 index 00000000000..fa787797d7a --- /dev/null +++ b/plugins/chat/spec/lib/chat_notifier_spec.rb @@ -0,0 +1,540 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatNotifier do + describe "#notify_new" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + + before do + @chat_group = + Fabricate( + :group, + users: [user_1, user_2], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + SiteSetting.chat_allowed_groups = @chat_group.id + + [user_1, user_2].each do |u| + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: u) + end + end + + def build_cooked_msg(message_body, user, chat_channel: channel) + ChatMessage.new( + chat_channel: chat_channel, + user: user, + message: message_body, + created_at: 5.minutes.ago, + ).tap(&:cook) + end + + shared_examples "channel-wide mentions" do + it "returns an empty list when the message doesn't include a channel mention" do + msg = build_cooked_msg(mention.gsub("@", ""), user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "will never include someone who is not accepting channel-wide notifications" do + user_2.user_option.update!(ignore_channel_wide_mention: true) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "includes all members of a channel except the sender" do + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + end + + shared_examples "ensure only channel members are notified" do + it "will never include someone outside the channel" do + user3 = Fabricate(:user) + @chat_group.add(user3) + another_channel = Fabricate(:category_channel) + Fabricate(:user_chat_channel_membership, chat_channel: another_channel, user: user3) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "will never include someone not following the channel anymore" do + user3 = Fabricate(:user) + @chat_group.add(user3) + Fabricate( + :user_chat_channel_membership, + following: false, + chat_channel: channel, + user: user3, + ) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "will never include someone who is suspended" do + user3 = Fabricate(:user, suspended_till: 2.years.from_now) + @chat_group.add(user3) + Fabricate( + :user_chat_channel_membership, + following: true, + chat_channel: channel, + user: user3, + ) + + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + end + + describe "global_mentions" do + let(:mention) { "hello @all!" } + let(:list_key) { :global_mentions } + + include_examples "channel-wide mentions" + include_examples "ensure only channel members are notified" + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "here_mentions" do + let(:mention) { "hello @here!" } + let(:list_key) { :here_mentions } + + before { user_2.update!(last_seen_at: 4.minutes.ago) } + + include_examples "channel-wide mentions" + include_examples "ensure only channel members are notified" + + it "includes users seen less than 5 minutes ago" do + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to contain_exactly(user_2.id) + end + + it "excludes users seen more than 5 minutes ago" do + user_2.update!(last_seen_at: 6.minutes.ago) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "excludes users mentioned directly" do + msg = build_cooked_msg("hello @here @#{user_2.username}!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[list_key]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg(mention, user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "direct_mentions" do + it "only include mentioned users who are already in the channel" do + user_3 = Fabricate(:user) + @chat_group.add(user_3) + another_channel = Fabricate(:category_channel) + Fabricate(:user_chat_channel_membership, chat_channel: another_channel, user: user_3) + msg = build_cooked_msg("Is @#{user_3.username} here? And @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + it "include users as direct mentions even if there's a @here mention" do + msg = build_cooked_msg("Hello @here and @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:here_mentions]).to be_empty + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + it "include users as direct mentions even if there's a @all mention" do + msg = build_cooked_msg("Hello @all and @#{user_2.username}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:global_mentions]).to be_empty + expect(to_notify[:direct_mentions]).to contain_exactly(user_2.id) + end + + describe "users ignoring or muting the user creating the message" do + it "does not publish new mentions to these users" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) + + ChatPublisher.expects(:publish_new_mention).never + to_notify = described_class.new(msg, msg.created_at).notify_new + end + + it "does not send notifications to the user who is muting the acting user" do + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + it "does not send notifications to the user who is ignoring the acting user" do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("hey @#{user_2.username} stop ignoring me!", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + end + end + + describe "group mentions" do + fab!(:user_3) { Fabricate(:user) } + fab!(:group) do + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + end + fab!(:other_channel) { Fabricate(:category_channel) } + + before { @chat_group.add(user_3) } + + let(:mention) { "hello @#{group.name}!" } + let(:list_key) { group.name } + + include_examples "ensure only channel members are notified" + + it "establishes a far-left precedence among group mentions" do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user_3, + following: true, + ) + msg = build_cooked_msg("Hello @#{@chat_group.name} and @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[@chat_group.name]).to contain_exactly(user_2.id, user_3.id) + expect(to_notify[list_key]).to be_empty + + second_msg = build_cooked_msg("Hello @#{group.name} and @#{@chat_group.name}", user_1) + + to_notify_2 = described_class.new(second_msg, second_msg.created_at).notify_new + + expect(to_notify_2[list_key]).to contain_exactly(user_2.id, user_3.id) + expect(to_notify_2[@chat_group.name]).to be_empty + end + + describe "users ignoring or muting the user creating the message" do + it "does not send notifications to the user inside the group who is muting the acting user" do + group.add(user_3) + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user_3) + Fabricate(:muted_user, user: user_2, muted_user: user_1) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + expect(to_notify[group.name]).to contain_exactly(user_3.id) + end + + it "does not send notifications to the user inside the group who is ignoring the acting user" do + group.add(user_3) + Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user_3) + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + expect(to_notify[group.name]).to contain_exactly(user_3.id) + end + end + end + + describe "unreachable users" do + fab!(:user_3) { Fabricate(:user) } + + it "notify poster of users who are not allowed to use chat" do + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + + context "when in a personal message" do + let(:personal_chat_channel) do + Group.refresh_automatic_groups! + Chat::DirectMessageChannelCreator.create!( + acting_user: user_1, + target_users: [user_1, user_2], + ) + end + + before { @chat_group.add(user_3) } + + it "notify posts of users who are not participating in a personal message" do + msg = + build_cooked_msg( + "Hello @#{user_3.username}", + user_1, + chat_channel: personal_chat_channel, + ) + + messages = + MessageBus.track_publish("/chat/#{personal_chat_channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + + it "notify posts of users who are part of the mentioned group but participating" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + msg = + build_cooked_msg("Hello @#{group.name}", user_1, chat_channel: personal_chat_channel) + + messages = + MessageBus.track_publish("/chat/#{personal_chat_channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[group.name]).to contain_exactly(user_2.id) + end + + unreachable_msg = messages.first + + expect(unreachable_msg).to be_present + expect(unreachable_msg.data[:without_membership]).to be_empty + unreachable_users = unreachable_msg.data[:cannot_see].map { |u| u[:id] } + expect(unreachable_users).to contain_exactly(user_3.id) + end + end + end + + describe "users who can be invited to join the channel" do + fab!(:user_3) { Fabricate(:user) } + + before { @chat_group.add(user_3) } + + it "can invite chat user without channel membership" do + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "cannot invite chat user without channel membership if they are ignoring the user who created the message" do + Fabricate(:ignored_user, user: user_3, ignored_user: user_1) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "cannot invite chat user without channel membership if they are muting the user who created the message" do + Fabricate(:muted_user, user: user_3, muted_user: user_1) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "can invite chat user who no longer follows the channel" do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user_3, + following: false, + ) + msg = build_cooked_msg("Hello @#{user_3.username}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "can invite other group members to channel" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + not_participating_msg = messages.first + + expect(not_participating_msg).to be_present + expect(not_participating_msg.data[:cannot_see]).to be_empty + not_participating_users = not_participating_msg.data[:without_membership].map { |u| u[:id] } + expect(not_participating_users).to contain_exactly(user_3.id) + end + + it "cannot invite a member of a group who is ignoring the user who created the message" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + Fabricate(:ignored_user, user: user_3, ignored_user: user_1, expiring_at: 1.day.from_now) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + + it "cannot invite a member of a group who is muting the user who created the message" do + group = + Fabricate( + :public_group, + users: [user_2, user_3], + mentionable_level: Group::ALIAS_LEVELS[:everyone], + ) + Fabricate(:muted_user, user: user_3, muted_user: user_1) + msg = build_cooked_msg("Hello @#{group.name}", user_1) + + messages = + MessageBus.track_publish("/chat/#{channel.id}") do + to_notify = described_class.new(msg, msg.created_at).notify_new + + expect(to_notify[:direct_mentions]).to be_empty + end + + expect(messages).to be_empty + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_review_queue_spec.rb b/plugins/chat/spec/lib/chat_review_queue_spec.rb new file mode 100644 index 00000000000..5c0539ed63e --- /dev/null +++ b/plugins/chat/spec/lib/chat_review_queue_spec.rb @@ -0,0 +1,440 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::ChatReviewQueue do + fab!(:message_poster) { Fabricate(:user) } + fab!(:flagger) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:message) { Fabricate(:chat_message, user: message_poster, chat_channel: chat_channel) } + + fab!(:admin) { Fabricate(:admin) } + let(:guardian) { Guardian.new(flagger) } + let(:admin_guardian) { Guardian.new(admin) } + + subject(:queue) { described_class.new } + + before do + chat_channel.add(message_poster) + chat_channel.add(flagger) + Group.refresh_automatic_groups! + end + + describe "#flag_message" do + it "raises an error when the user is not allowed to flag" do + UserSilencer.new(flagger).silence + + expect { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) }.to raise_error( + Discourse::InvalidAccess, + ) + end + + it "stores the message cooked content inside the reviewable" do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + + expect(reviewable.payload["message_cooked"]).to eq(message.cooked) + end + + context "when the user already flagged the post" do + let(:second_flag_result) do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + end + + before { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + + it "returns an error" do + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + + it "returns an error when trying to use notify_moderators and the previous flag is still pending" do + notify_moderators_result = + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: "Look at this please, moderators", + ) + + expect(notify_moderators_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + end + + context "when a different user already flagged the post" do + let(:second_flag_result) { queue.flag_message(message, admin_guardian, second_flag_type) } + + before { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + + it "appends a new score to the existing reviewable" do + second_flag_result = + queue.flag_message(message, admin_guardian, ReviewableScore.types[:off_topic]) + expect(second_flag_result).to include success: true + + reviewable = ReviewableChatMessage.find_by(target: message) + scores = reviewable.reviewable_scores + + expect(scores.size).to eq(2) + expect(scores.map(&:reviewable_score_type)).to contain_exactly( + *ReviewableScore.types.slice(:off_topic, :spam).values, + ) + end + + it "returns an error when someone already used the same flag type" do + second_flag_result = + queue.flag_message(message, admin_guardian, ReviewableScore.types[:spam]) + + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + end + + context "when a flags exists but staff already handled it" do + let(:second_flag_result) do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + end + + before do + queue.flag_message(message, guardian, ReviewableScore.types[:spam]) + + reviewable = ReviewableChatMessage.last + reviewable.perform(admin, :ignore) + end + + it "raises an error when we are inside the cooldown window" do + expect(second_flag_result).to include success: false, + errors: [I18n.t("chat.reviewables.message_already_handled")] + end + + it "allows the user to re-flag after the cooldown period" do + reviewable = ReviewableChatMessage.last + reviewable.update!(updated_at: (SiteSetting.cooldown_hours_until_reflag.to_i + 1).hours.ago) + + expect(second_flag_result).to include success: true + end + + it "ignores the cooldown window when the message is edited" do + Chat::ChatMessageUpdater.update( + chat_message: message, + new_content: "I'm editing this message. Please flag it.", + ) + + expect(second_flag_result).to include success: true + end + + it "ignores the cooldown window when using the notify_moderators flag type" do + notify_moderators_result = + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: "Look at this please, moderators", + ) + + expect(notify_moderators_result).to include success: true + end + end + + it "publishes a message to the flagger" do + messages = + MessageBus + .track_publish { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + .map(&:data) + + self_flag_msg = messages.detect { |m| m["type"] == "self_flagged" } + + expect(self_flag_msg["user_flag_status"]).to eq(ReviewableScore.statuses[:pending]) + expect(self_flag_msg["chat_message_id"]).to eq(message.id) + end + + it "publishes a message to tell staff there is a new reviewable" do + messages = + MessageBus + .track_publish { queue.flag_message(message, guardian, ReviewableScore.types[:spam]) } + .map(&:data) + + flag_msg = messages.detect { |m| m["type"] == "flag" } + new_reviewable = ReviewableChatMessage.find_by(target: message) + + expect(flag_msg["chat_message_id"]).to eq(message.id) + expect(flag_msg["reviewable_id"]).to eq(new_reviewable.id) + end + + let(:flag_message) { "I just flagged your chat message..." } + + context "when creating a notify_user flag" do + it "creates a companion PM" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + ) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + pm_post = pm_topic.first_post + + expect(pm_topic.allowed_users).to include(message.user) + expect(pm_topic.subtype).to eq(TopicSubtype.notify_user) + expect(pm_post.raw).to include(flag_message) + expect(pm_topic.title).to eq("Your chat message in \"#{chat_channel.title(message.user)}\"") + end + + it "doesn't create a PM if there is no message" do + queue.flag_message(message, guardian, ReviewableScore.types[:notify_user]) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + + expect(pm_topic).to be_nil + end + + it "allow staff to tag PM as a warning" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + is_warning: true, + ) + + expect(UserWarning.exists?(user: message.user)).to eq(true) + end + + it "only allows staff members to send warnings" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_user], + message: flag_message, + is_warning: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when creating a notify_moderators flag" do + it "creates a companion PM and gives moderators access to it" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: flag_message, + ) + + pm_topic = + Topic.includes(:posts).find_by(user: guardian.user, archetype: Archetype.private_message) + pm_post = pm_topic.first_post + + expect(pm_topic.allowed_groups).to contain_exactly(Group[:moderators]) + expect(pm_topic.subtype).to eq(TopicSubtype.notify_moderators) + expect(pm_post.raw).to include(flag_message) + expect(pm_topic.title).to eq( + "A chat message in \"#{chat_channel.title(message.user)}\" requires staff attention", + ) + end + + it "ignores the is_warning flag when notifying moderators" do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:notify_moderators], + message: flag_message, + is_warning: true, + ) + + expect(UserWarning.exists?(user: message.user)).to eq(false) + end + end + + context "when immediately taking action" do + it "agrees with the flag and deletes the chat message" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + + reviewable = ReviewableChatMessage.find_by(target: message) + + expect(reviewable.approved?).to eq(true) + expect(message.reload.trashed?).to eq(true) + end + + it "publishes an when deleting the message" do + messages = + MessageBus + .track_publish do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + end + .map(&:data) + + delete_msg = messages.detect { |m| m[:type] == "delete" } + + expect(delete_msg[:deleted_id]).to eq(message.id) + end + + it "agrees with other flags on the same message" do + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + scores = reviewable.reviewable_scores + + expect(scores.size).to eq(1) + expect(scores.all?(&:pending?)).to eq(true) + + queue.flag_message(message, admin_guardian, ReviewableScore.types[:spam], take_action: true) + + scores = reviewable.reload.reviewable_scores + + expect(scores.size).to eq(2) + expect(scores.all?(&:agreed?)).to eq(true) + end + + it "raises an exception if the user is not a staff member" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:off_topic], + take_action: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when queueing for review" do + it "sets a reason on the score" do + queue.flag_message( + message, + admin_guardian, + ReviewableScore.types[:off_topic], + queue_for_review: true, + ) + + reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + score = reviewable.reviewable_scores.first + + expect(score.reason).to eq("chat_message_queued_by_staff") + end + + it "only allows staff members to queue for review" do + expect do + queue.flag_message( + message, + guardian, + ReviewableScore.types[:off_topic], + queue_for_review: true, + ) + end.to raise_error(Discourse::InvalidAccess) + end + end + + context "when the auto silence threshold is met" do + it "silences the user" do + SiteSetting.chat_auto_silence_from_flags_duration = 1 + flagger.update!(trust_level: TrustLevel[4]) # Increase Score due to TL Bonus. + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(true) + end + + it "does nothing if the new score is less than the auto-silence threshold" do + SiteSetting.chat_auto_silence_from_flags_duration = 50 + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(false) + end + + it "does nothing if the silence duration is set to 0" do + SiteSetting.chat_auto_silence_from_flags_duration = 0 + flagger.update!(trust_level: TrustLevel[4]) # Increase Score due to TL Bonus. + + queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) + + expect(message_poster.reload.silenced?).to eq(false) + end + end + + context "when flagging a DM" do + fab!(:dm_channel) do + Fabricate(:direct_message_chat_channel, users: [message_poster, flagger]) + end + + 12.times do |i| + fab!("dm_message_#{i + 1}") do + Fabricate( + :chat_message, + user: message_poster, + chat_channel: dm_channel, + message: "This is my message number #{i + 1}. Hello chat!", + ) + end + end + + it "raises an exception when using the notify_moderators flag type" do + expect { + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:notify_moderators]) + }.to raise_error(Discourse::InvalidParameters) + end + + it "raises an exception when using the notify_user flag type" do + expect { + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:notify_user]) + }.to raise_error(Discourse::InvalidParameters) + end + + it "includes a transcript of the previous 10 message for the rest of the flags" do + queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + expect(reviewable.target).to eq(dm_message_12) + transcript_post = Post.find_by(topic_id: reviewable.payload["transcript_topic_id"]) + + expect(transcript_post.cooked).to include(dm_message_2.message) + expect(transcript_post.cooked).to include(dm_message_5.message) + expect(transcript_post.cooked).not_to include(dm_message_1.message) + end + + it "doesn't include a transcript if there a no previous messages" do + queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + + expect(reviewable.payload["transcript_topic_id"]).to be_nil + end + + it "the transcript is only available to moderators and the system user" do + moderator = Fabricate(:moderator) + admin = Fabricate(:admin) + leader = Fabricate(:leader) + tl4 = Fabricate(:trust_level_4) + + queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) + + reviewable = ReviewableChatMessage.last + transcript_topic = Topic.find(reviewable.payload["transcript_topic_id"]) + + expect(guardian.can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(leader).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(tl4).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(dm_message_12.user).can_see_topic?(transcript_topic)).to eq(false) + expect(Guardian.new(moderator).can_see_topic?(transcript_topic)).to eq(true) + expect(Guardian.new(admin).can_see_topic?(transcript_topic)).to eq(true) + expect(Guardian.new(Discourse.system_user).can_see_topic?(transcript_topic)).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_statistics_spec.rb b/plugins/chat/spec/lib/chat_statistics_spec.rb new file mode 100644 index 00000000000..02154128316 --- /dev/null +++ b/plugins/chat/spec/lib/chat_statistics_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +describe Chat::Statistics do + fab!(:frozen_time) { DateTime.parse("2022-07-08 09:30:00") } + + def minus_time(time) + frozen_time - time + end + + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:user4) { Fabricate(:user) } + fab!(:user5) { Fabricate(:user) } + + fab!(:channel1) { Fabricate(:chat_channel, created_at: minus_time(1.hour)) } + fab!(:channel2) { Fabricate(:chat_channel, created_at: minus_time(2.days)) } + fab!(:channel3) { Fabricate(:chat_channel, created_at: minus_time(6.days)) } + fab!(:channel3) { Fabricate(:chat_channel, created_at: minus_time(20.days)) } + fab!(:channel4) { Fabricate(:chat_channel, created_at: minus_time(21.days), status: :closed) } + fab!(:channel5) { Fabricate(:chat_channel, created_at: minus_time(24.days)) } + fab!(:channel6) { Fabricate(:chat_channel, created_at: minus_time(40.days)) } + fab!(:channel7) { Fabricate(:chat_channel, created_at: minus_time(100.days), status: :archived) } + + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user2, chat_channel: channel1) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership, user: user3, chat_channel: channel1) + end + + fab!(:message1) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(5.minutes), user: user1) + end + fab!(:message2) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(2.days), user: user2) + end + fab!(:message3) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(6.days), user: user2) + end + fab!(:message4) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(11.days), user: user2) + end + fab!(:message5) do + Fabricate(:chat_message, chat_channel: channel4, created_at: minus_time(12.days), user: user3) + end + fab!(:message6) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(13.days), user: user2) + end + fab!(:message7) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(16.days), user: user1) + end + fab!(:message8) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(42.days), user: user3) + end + fab!(:message9) do + Fabricate( + :chat_message, + chat_channel: channel1, + created_at: minus_time(42.days), + user: user3, + deleted_at: minus_time(10.days), + deleted_by: user3, + ) + end + fab!(:message10) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(50.days), user: user4) + end + fab!(:message10) do + Fabricate(:chat_message, chat_channel: channel1, created_at: minus_time(62.days), user: user4) + end + + before { freeze_time(DateTime.parse("2022-07-08 09:30:00")) } + + describe "#about_messages" do + it "counts non-deleted messages created in all status channels in the time period accurately" do + about_messages = described_class.about_messages + expect(about_messages[:last_day]).to eq(1) + expect(about_messages["7_days"]).to eq(3) + expect(about_messages["30_days"]).to eq(7) + expect(about_messages[:previous_30_days]).to eq(2) + expect(about_messages[:count]).to eq(10) + end + end + + describe "#about_channels" do + it "counts open channels created in the time period accurately" do + about_channels = described_class.about_channels + expect(about_channels[:last_day]).to eq(1) + expect(about_channels["7_days"]).to eq(3) + expect(about_channels["30_days"]).to eq(5) + expect(about_channels[:previous_30_days]).to eq(1) + expect(about_channels[:count]).to eq(6) + end + end + + describe "#about_users" do + it "counts any users who have sent any message to a chat channel in the time periods accurately" do + about_users = described_class.about_users + expect(about_users[:last_day]).to eq(1) + expect(about_users["7_days"]).to eq(2) + expect(about_users["30_days"]).to eq(3) + expect(about_users[:previous_30_days]).to eq(2) + expect(about_users[:count]).to eq(4) + end + end + + describe "#monthly" do + it "has the correct counts of users, messages, and channels created since the start of this month" do + monthly = described_class.monthly + expect(monthly[:messages]).to eq(3) + expect(monthly[:channels]).to eq(3) + expect(monthly[:users]).to eq(2) + end + end +end diff --git a/plugins/chat/spec/lib/chat_transcript_service_spec.rb b/plugins/chat/spec/lib/chat_transcript_service_spec.rb new file mode 100644 index 00000000000..0f14d92f016 --- /dev/null +++ b/plugins/chat/spec/lib/chat_transcript_service_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatTranscriptService do + let(:acting_user) { Fabricate(:user) } + let(:user1) { Fabricate(:user, username: "martinchat") } + let(:user2) { Fabricate(:user, username: "brucechat") } + let(:channel) { Fabricate(:category_channel, name: "The Beam Discussions") } + + def service(message_ids, opts: {}) + described_class.new(channel, acting_user, messages_or_ids: Array.wrap(message_ids), opts: opts) + end + + it "generates a simple chat transcript from one message" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + an extremely insightful response :) + [/chat] + MARKDOWN + end + + it "generates a single chat transcript from multiple subsequent messages from the same user" do + message1 = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = + Fabricate(:chat_message, user: user1, chat_channel: channel, message: "if i say so myself") + message3 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "yay!") + + rendered = service([message1.id, message2.id, message3.id]).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true"] + an extremely insightful response :) + + if i say so myself + + yay! + [/chat] + MARKDOWN + end + + it "generates chat messages in created_at order no matter what order the message_ids are passed in" do + message1 = + Fabricate( + :chat_message, + created_at: 10.minute.ago, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = + Fabricate( + :chat_message, + created_at: 5.minutes.ago, + user: user1, + chat_channel: channel, + message: "if i say so myself", + ) + message3 = + Fabricate( + :chat_message, + created_at: 1.minutes.ago, + user: user1, + chat_channel: channel, + message: "yay!", + ) + + rendered = service([message3.id, message1.id, message2.id]).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true"] + an extremely insightful response :) + + if i say so myself + + yay! + [/chat] + MARKDOWN + end + + it "generates multiple chained chat transcripts for interleaving messages from different users" do + message1 = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = Fabricate(:chat_message, user: user2, chat_channel: channel, message: "says you!") + message3 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "aw :(") + + expect(service([message1.id, message2.id, message3.id]).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message1.id};#{message1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true"] + an extremely insightful response :) + [/chat] + + [chat quote="brucechat;#{message2.id};#{message2.created_at.iso8601}" chained="true"] + says you! + [/chat] + + [chat quote="martinchat;#{message3.id};#{message3.created_at.iso8601}" chained="true"] + aw :( + [/chat] + MARKDOWN + end + + it "generates image / attachment / video / audio markdown inside the [chat] bbcode for upload-only messages" do + SiteSetting.authorized_extensions = "mp4|mp3|pdf|jpg" + message = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "") + video = Fabricate(:upload, original_filename: "test_video.mp4", extension: "mp4") + audio = Fabricate(:upload, original_filename: "test_audio.mp3", extension: "mp3") + attachment = Fabricate(:upload, original_filename: "test_file.pdf", extension: "pdf") + image = + Fabricate( + :upload, + width: 100, + height: 200, + original_filename: "test_img.jpg", + extension: "jpg", + ) + cu1 = ChatUpload.create(chat_message: message, created_at: 10.seconds.ago, upload: video) + cu2 = ChatUpload.create(chat_message: message, created_at: 9.seconds.ago, upload: audio) + cu3 = ChatUpload.create(chat_message: message, created_at: 8.seconds.ago, upload: attachment) + cu4 = ChatUpload.create(chat_message: message, created_at: 7.seconds.ago, upload: image) + video_markdown = UploadMarkdown.new(video).to_markdown + audio_markdown = UploadMarkdown.new(audio).to_markdown + attachment_markdown = UploadMarkdown.new(attachment).to_markdown + image_markdown = UploadMarkdown.new(image).to_markdown + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + #{video_markdown} + #{audio_markdown} + #{attachment_markdown} + #{image_markdown} + [/chat] + MARKDOWN + end + + it "generates the correct markdown if a message has text and an upload" do + SiteSetting.authorized_extensions = "mp4|mp3|pdf|jpg" + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "this is a cool and funny picture", + ) + image = + Fabricate( + :upload, + width: 100, + height: 200, + original_filename: "test_img.jpg", + extension: "jpg", + ) + cu = ChatUpload.create(chat_message: message, created_at: 7.seconds.ago, upload: image) + image_markdown = UploadMarkdown.new(image).to_markdown + + expect(service(message.id).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}"] + this is a cool and funny picture + + #{image_markdown} + [/chat] + MARKDOWN + end + + it "generates a transcript with the noLink option" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + + expect(service(message.id, opts: { no_link: true }).generate_markdown).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" noLink="true"] + an extremely insightful response :) + [/chat] + MARKDOWN + end + + it "generates reaction data for single and subsequent messages" do + message = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + message2 = Fabricate(:chat_message, user: user1, chat_channel: channel, message: "wow so tru") + message3 = + Fabricate(:chat_message, user: user2, chat_channel: channel, message: "a new perspective") + + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "bjorn"), + emoji: "heart", + ) + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "sigurd"), + emoji: "heart", + ) + ChatMessageReaction.create!( + chat_message: message, + user: Fabricate(:user, username: "hvitserk"), + emoji: "+1", + ) + ChatMessageReaction.create!( + chat_message: message2, + user: Fabricate(:user, username: "ubbe"), + emoji: "money_mouth_face", + ) + ChatMessageReaction.create!( + chat_message: message3, + user: Fabricate(:user, username: "ivar"), + emoji: "sob", + ) + + expect( + service( + [message.id, message2.id, message3.id], + opts: { + include_reactions: true, + }, + ).generate_markdown, + ).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{message.id};#{message.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true" reactions="+1:hvitserk;heart:bjorn,sigurd;money_mouth_face:ubbe"] + an extremely insightful response :) + + wow so tru + [/chat] + + [chat quote="brucechat;#{message3.id};#{message3.created_at.iso8601}" chained="true" reactions="sob:ivar"] + a new perspective + [/chat] + MARKDOWN + end +end diff --git a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb b/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb new file mode 100644 index 00000000000..aa8dad3eb6a --- /dev/null +++ b/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::DirectMessageChannelCreator do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:user_3) { Fabricate(:user) } + + before { Group.refresh_automatic_groups! } + + context "with an existing direct message channel" do + fab!(:dm_chat_channel) do + Fabricate( + :chat_channel, + chatable: Fabricate(:direct_message_channel, users: [user_1, user_2, user_3]), + ) + end + fab!(:own_chat_channel) do + Fabricate(:chat_channel, chatable: Fabricate(:direct_message_channel, users: [user_1])) + end + + it "doesn't create a new chat channel" do + existing_channel = nil + expect { + existing_channel = + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { ChatChannel.count } + expect(existing_channel).to eq(dm_chat_channel) + end + + it "creates UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do + Fabricate( + :user_chat_channel_membership, + user: user_2, + chat_channel: dm_chat_channel, + following: false, + muted: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + Fabricate( + :user_chat_channel_membership, + user: user_3, + chat_channel: dm_chat_channel, + following: false, + muted: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + ) + + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { UserChatChannelMembership.count }.by(1) + + user_1_membership = + UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: dm_chat_channel) + expect(user_1_membership.last_read_message_id).to eq(nil) + expect(user_1_membership.desktop_notification_level).to eq("always") + expect(user_1_membership.mobile_notification_level).to eq("always") + expect(user_1_membership.muted).to eq(false) + expect(user_1_membership.following).to eq(true) + + user_2_membership = + UserChatChannelMembership.find_by(user_id: user_2.id, chat_channel_id: dm_chat_channel) + expect(user_2_membership.last_read_message_id).to eq(nil) + expect(user_2_membership.desktop_notification_level).to eq("never") + expect(user_2_membership.mobile_notification_level).to eq("never") + expect(user_2_membership.muted).to eq(true) + expect(user_2_membership.following).to eq(false) + + user_3_membership = + UserChatChannelMembership.find_by(user_id: user_3.id, chat_channel_id: dm_chat_channel) + expect(user_3_membership.last_read_message_id).to eq(nil) + expect(user_3_membership.desktop_notification_level).to eq("never") + expect(user_3_membership.mobile_notification_level).to eq("never") + expect(user_3_membership.muted).to eq(true) + expect(user_3_membership.following).to eq(false) + end + + it "publishes the new DM channel message bus message for each user" do + messages = + MessageBus + .track_publish do + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + end + .filter { |m| m.channel == "/chat/new-channel" } + + expect(messages.count).to eq(3) + expect(messages.first[:data]).to be_kind_of(Hash) + expect(messages.map { |m| m.dig(:data, :chat_channel, :id) }).to eq( + [dm_chat_channel.id, dm_chat_channel.id, dm_chat_channel.id], + ) + end + + it "allows a user to create a direct message to themselves, without creating a new channel" do + existing_channel = nil + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1]) + }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + expect(existing_channel).to eq(own_chat_channel) + end + + it "deduplicates target_users" do + existing_channel = nil + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + expect(existing_channel).to eq(own_chat_channel) + end + + context "when the user is not a member of direct_message_enabled_groups" do + before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } + + it "raises an error and does not change membership or channel counts" do + channel_count = ChatChannel.count + membership_count = UserChatChannelMembership.count + expect { + existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to raise_error(Discourse::InvalidAccess) + expect(ChatChannel.count).to eq(channel_count) + expect(UserChatChannelMembership.count).to eq(membership_count) + end + + context "when user is staff" do + before { user_1.update!(admin: true) } + + it "doesn't create an error and returns the existing channel" do + existing_channel = nil + expect { + existing_channel = + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { ChatChannel.count } + expect(existing_channel).to eq(dm_chat_channel) + end + end + end + end + + context "with non existing direct message channel" do + it "creates a new chat channel" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { + ChatChannel.count + }.by(1) + end + + it "creates UserChatChannelMembership records and sets their notification levels" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { + UserChatChannelMembership.count + }.by(2) + + chat_channel = ChatChannel.last + user_1_membership = + UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) + expect(user_1_membership.last_read_message_id).to eq(nil) + expect(user_1_membership.desktop_notification_level).to eq("always") + expect(user_1_membership.mobile_notification_level).to eq("always") + expect(user_1_membership.muted).to eq(false) + expect(user_1_membership.following).to eq(true) + end + + it "publishes the new DM channel message bus message for each user" do + messages = + MessageBus + .track_publish { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) } + .filter { |m| m.channel == "/chat/new-channel" } + + chat_channel = ChatChannel.last + expect(messages.count).to eq(2) + expect(messages.first[:data]).to be_kind_of(Hash) + expect(messages.map { |m| m.dig(:data, :chat_channel, :id) }).to eq( + [chat_channel.id, chat_channel.id], + ) + end + + it "allows a user to create a direct message to themselves" do + expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { + ChatChannel.count + }.by(1).and change { UserChatChannelMembership.count }.by(1) + end + + it "deduplicates target_users" do + expect { subject.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to change { + ChatChannel.count + }.by(1).and change { UserChatChannelMembership.count }.by(1) + end + + context "when the user is not a member of direct_message_enabled_groups" do + before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } + + it "raises an error and does not change membership or channel counts" do + channel_count = ChatChannel.count + membership_count = UserChatChannelMembership.count + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to raise_error(Discourse::InvalidAccess) + expect(ChatChannel.count).to eq(channel_count) + expect(UserChatChannelMembership.count).to eq(membership_count) + end + + context "when user is staff" do + before { user_1.update!(admin: true) } + + it "creates a new chat channel" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { ChatChannel.count }.by(1) + end + end + end + end + + describe "ignoring, muting, and preventing DMs from other users" do + context "when any of the users that the acting user is open in a DM with are ignoring the acting user" do + before do + Fabricate(:ignored_user, user: user_2, ignored_user: user_1, expiring_at: 1.day.from_now) + end + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the ignoring user create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_ignoring_target_user", username: user_1.username), + ) + end + end + + context "when any of the users that the acting user is open in a DM with are muting the acting user" do + before { Fabricate(:muted_user, user: user_2, muted_user: user_1) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the muting user create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_muting_target_user", username: user_1.username), + ) + end + end + + context "when any of the users that the acting user is open in a DM with is preventing private/direct messages" do + before { user_2.user_option.update(allow_private_messages: false) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.not_accepting_dms", username: user_2.username), + ) + end + + it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_disallowed_dms"), + ) + end + end + + context "when any of the users that the acting user is open in a DM with only allow private/direct messages from certain users" do + before { user_2.user_option.update!(enable_allowed_pm_users: true) } + + it "raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to raise_error(Chat::DirectMessageChannelCreator::NotAllowed) + end + + it "does not raise an error if the acting user is allowed to send the PM" do + AllowedPmUser.create!(user: user_2, allowed_pm_user: user_1) + expect { + subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { ChatChannel.count }.by(1) + end + + it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do + expect { + subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + }.to raise_error( + Chat::DirectMessageChannelCreator::NotAllowed, + I18n.t("chat.errors.actor_preventing_target_user_from_dm", username: user_1.username), + ) + end + end + end +end diff --git a/plugins/chat/spec/lib/duplicate_message_validator_spec.rb b/plugins/chat/spec/lib/duplicate_message_validator_spec.rb new file mode 100644 index 00000000000..4da4da7bddf --- /dev/null +++ b/plugins/chat/spec/lib/duplicate_message_validator_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::DuplicateMessageValidator do + let(:chat_channel) { Fabricate(:chat_channel) } + + def message_blocked?(message) + chat_message = Fabricate.build(:chat_message, message: message, chat_channel: chat_channel) + described_class.new(chat_message).validate + chat_message.errors.full_messages.include?(I18n.t("chat.errors.duplicate_message")) + end + + it "adds no errors when chat_duplicate_message_sensitivity is 0" do + SiteSetting.chat_duplicate_message_sensitivity = 0 + expect(message_blocked?("test")).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 0.1" do + SiteSetting.chat_duplicate_message_sensitivity = 0.1 + + chat_channel.update!(user_count: 100) + message = "this is a 30 char message for test" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 11.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 0.5" do + SiteSetting.chat_duplicate_message_sensitivity = 0.5 + chat_channel.update!(user_count: 57) + message = "this is a 21 char msg" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 33.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + it "errors if the message meets the requirements for sensitivity 1.0" do + SiteSetting.chat_duplicate_message_sensitivity = 1.0 + chat_channel.update!(user_count: 5) + message = "10 char msg" + dupe = + Fabricate( + :chat_message, + created_at: 1.second.ago, + message: message, + chat_channel: chat_channel, + ) + expect(message_blocked?(message)).to eq(true) + + expect(message_blocked?("blah")).to eq(false) + + dupe.update!(created_at: 61.seconds.ago) + expect(message_blocked?(message)).to eq(false) + end + + describe "#sensitivity_matrix" do + describe "#min_user_count" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_user_count]).to eq(100) + expect(described_class.sensitivity_matrix(0.2)[:min_user_count]).to eq(89) + expect(described_class.sensitivity_matrix(0.3)[:min_user_count]).to eq(78) + expect(described_class.sensitivity_matrix(0.4)[:min_user_count]).to eq(68) + expect(described_class.sensitivity_matrix(0.5)[:min_user_count]).to eq(57) + expect(described_class.sensitivity_matrix(0.6)[:min_user_count]).to eq(47) + expect(described_class.sensitivity_matrix(0.7)[:min_user_count]).to eq(36) + expect(described_class.sensitivity_matrix(0.8)[:min_user_count]).to eq(26) + expect(described_class.sensitivity_matrix(0.9)[:min_user_count]).to eq(15) + expect(described_class.sensitivity_matrix(1.0)[:min_user_count]).to eq(5) + end + end + + describe "#min_message_length" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_message_length]).to eq(30) + expect(described_class.sensitivity_matrix(0.2)[:min_message_length]).to eq(27) + expect(described_class.sensitivity_matrix(0.3)[:min_message_length]).to eq(25) + expect(described_class.sensitivity_matrix(0.4)[:min_message_length]).to eq(23) + expect(described_class.sensitivity_matrix(0.5)[:min_message_length]).to eq(21) + expect(described_class.sensitivity_matrix(0.6)[:min_message_length]).to eq(18) + expect(described_class.sensitivity_matrix(0.7)[:min_message_length]).to eq(16) + expect(described_class.sensitivity_matrix(0.8)[:min_message_length]).to eq(14) + expect(described_class.sensitivity_matrix(0.9)[:min_message_length]).to eq(12) + expect(described_class.sensitivity_matrix(1.0)[:min_message_length]).to eq(10) + end + end + + describe "#min_past_seconds" do + it "calculates correctly for each of the major points from 0.1 to 1.0" do + expect(described_class.sensitivity_matrix(0.1)[:min_past_seconds]).to eq(10) + expect(described_class.sensitivity_matrix(0.2)[:min_past_seconds]).to eq(15) + expect(described_class.sensitivity_matrix(0.3)[:min_past_seconds]).to eq(21) + expect(described_class.sensitivity_matrix(0.4)[:min_past_seconds]).to eq(26) + expect(described_class.sensitivity_matrix(0.5)[:min_past_seconds]).to eq(32) + expect(described_class.sensitivity_matrix(0.6)[:min_past_seconds]).to eq(37) + expect(described_class.sensitivity_matrix(0.7)[:min_past_seconds]).to eq(43) + expect(described_class.sensitivity_matrix(0.8)[:min_past_seconds]).to eq(48) + expect(described_class.sensitivity_matrix(0.9)[:min_past_seconds]).to eq(54) + expect(described_class.sensitivity_matrix(1.0)[:min_past_seconds]).to eq(60) + end + end + end +end diff --git a/plugins/chat/spec/lib/guardian_extensions_spec.rb b/plugins/chat/spec/lib/guardian_extensions_spec.rb new file mode 100644 index 00000000000..a54eefa3882 --- /dev/null +++ b/plugins/chat/spec/lib/guardian_extensions_spec.rb @@ -0,0 +1,361 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::GuardianExtensions do + fab!(:user) { Fabricate(:user) } + fab!(:staff) { Fabricate(:user, admin: true) } + fab!(:chat_group) { Fabricate(:group) } + fab!(:channel) { Fabricate(:category_channel) } + fab!(:dm_channel) { Fabricate(:direct_message_chat_channel) } + let(:guardian) { Guardian.new(user) } + let(:staff_guardian) { Guardian.new(staff) } + + before do + SiteSetting.chat_allowed_groups = chat_group.id + chat_group.add(user) + end + + it "cannot chat if the user is not in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = "" + expect(guardian.can_chat?(user)).to eq(false) + end + + it "staff can always chat regardless of chat_allowed_grups" do + SiteSetting.chat_allowed_groups = "" + expect(guardian.can_chat?(staff)).to eq(true) + end + + describe "chat channel" do + it "only staff can create channels" do + expect(guardian.can_create_chat_channel?).to eq(false) + expect(staff_guardian.can_create_chat_channel?).to eq(true) + end + + it "only staff can edit chat channels" do + expect(guardian.can_edit_chat_channel?).to eq(false) + expect(staff_guardian.can_edit_chat_channel?).to eq(true) + end + + it "only staff can close chat channels" do + channel.update(status: :open) + expect(guardian.can_change_channel_status?(channel, :closed)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :closed)).to eq(true) + end + + it "only staff can open chat channels" do + channel.update(status: :closed) + expect(guardian.can_change_channel_status?(channel, :open)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :open)).to eq(true) + end + + it "only staff can archive chat channels" do + channel.update(status: :read_only) + expect(guardian.can_change_channel_status?(channel, :archived)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :archived)).to eq(true) + end + + it "only staff can mark chat channels read_only" do + channel.update(status: :open) + expect(guardian.can_change_channel_status?(channel, :read_only)).to eq(false) + expect(staff_guardian.can_change_channel_status?(channel, :read_only)).to eq(true) + end + + describe "#can_see_chat_channel?" do + context "for direct message channels" do + fab!(:chatable) { Fabricate(:direct_message_channel) } + fab!(:channel) { Fabricate(:dm_channel, chatable: chatable) } + + it "returns false if the user is not part of the direct message" do + expect(guardian.can_see_chat_channel?(channel)).to eq(false) + end + + it "returns true if the user is part of the direct message" do + DirectMessageUser.create!(user: user, direct_message_channel_id: chatable.id) + expect(guardian.can_see_chat_channel?(channel)).to eq(true) + end + end + + context "for category channel" do + fab!(:category) { Fabricate(:category, read_restricted: true) } + + before { channel.update(chatable: category) } + + it "returns true if the user can see the category" do + expect(Guardian.new(user).can_see_chat_channel?(channel)).to eq(false) + group = Fabricate(:group) + CategoryGroup.create(group: group, category: category) + GroupUser.create(group: group, user: user) + + # have to make a new instance of guardian because `user.secure_category_ids` + # is memoized there + expect(Guardian.new(user).can_see_chat_channel?(channel)).to eq(true) + end + end + end + + describe "#can_flag_in_chat_channel?" do + alias_matcher :be_able_to_flag_in_chat_channel, :be_can_flag_in_chat_channel + + context "when channel is a direct message channel" do + let(:channel) { Fabricate(:dm_channel) } + + it "returns false" do + expect(guardian).not_to be_able_to_flag_in_chat_channel(channel) + end + end + + context "when channel is a category channel" do + it "returns true" do + expect(guardian).to be_able_to_flag_in_chat_channel(channel) + end + end + + context "with a private channel" do + let(:private_group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: private_group) } + let(:private_channel) { Fabricate(:category_channel, chatable: private_category) } + + context "when the user can't see the channel" do + it "returns false" do + expect(guardian).not_to be_able_to_flag_in_chat_channel(private_channel) + end + end + + context "when the user can see the channel" do + before { private_group.add(user) } + + it "returns true" do + expect(guardian).to be_able_to_flag_in_chat_channel(private_channel) + end + end + end + end + + describe "#can_moderate_chat?" do + context "for category channel" do + fab!(:category) { Fabricate(:category, read_restricted: true) } + + before { channel.update(chatable: category) } + + it "returns true for staff and false for regular users" do + expect(staff_guardian.can_moderate_chat?(channel.chatable)).to eq(true) + expect(guardian.can_moderate_chat?(channel.chatable)).to eq(false) + end + + context "when enable_category_group_moderation is true" do + before { SiteSetting.enable_category_group_moderation = true } + + it "returns true if the regular user is part of the reviewable_by_group for the category" do + moderator = Fabricate(:user) + mods = Fabricate(:group) + mods.add(moderator) + category.update!(reviewable_by_group: mods) + expect(Guardian.new(Fabricate(:admin)).can_moderate_chat?(channel.chatable)).to eq(true) + expect(Guardian.new(moderator).can_moderate_chat?(channel.chatable)).to eq(true) + end + end + end + + context "for DM channel" do + fab!(:dm_channel) { DirectMessageChannel.create! } + + before { channel.update(chatable_type: "DirectMessageType", chatable: dm_channel) } + + it "returns true for staff and false for regular users" do + expect(staff_guardian.can_moderate_chat?(channel.chatable)).to eq(true) + expect(guardian.can_moderate_chat?(channel.chatable)).to eq(false) + end + end + end + + describe "#can_restore_chat?" do + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: user) } + fab!(:chatable) { Fabricate(:category) } + + context "when channel is closed" do + before { channel.update!(status: :closed) } + + it "disallows a owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + it "allows a staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessageChannel.create! } + + it "allows owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + + context "when user is not owner of the message" do + fab!(:message) { Fabricate(:chat_message, chat_channel: channel, user: Fabricate(:user)) } + + context "when chatable is a category" do + context "when category is not restricted" do + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + end + + context "when category is restricted" do + fab!(:chatable) { Fabricate(:category, read_restricted: true) } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + context "when group moderation is enabled" do + before { SiteSetting.enable_category_group_moderation = true } + + it "allows a group moderator to restore" do + moderator = Fabricate(:user) + mods = Fabricate(:group) + mods.add(moderator) + chatable.update!(reviewable_by_group: mods) + expect(Guardian.new(moderator).can_restore_chat?(message, chatable)).to eq(true) + end + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessageChannel.create! } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "disallows any user to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + end + end + end + + context "when user is owner of the message" do + context "when chatable is a category" do + it "allows to restore if owner can see category" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + context "when category is restricted" do + fab!(:chatable) { Fabricate(:category, read_restricted: true) } + + it "disallows to restore if owner can't see category" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(false) + end + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + end + + context "when chatable is a direct message" do + fab!(:chatable) { DirectMessageChannel.create! } + + it "allows staff to restore" do + expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) + end + + it "allows owner to restore" do + expect(guardian.can_restore_chat?(message, chatable)).to eq(true) + end + end + end + end + + describe "#can_delete_category?" do + alias_matcher :be_able_to_delete_category, :be_can_delete_category + + let(:category) { channel.chatable } + + context "when category has no channel" do + before do + category.category_channel.destroy + category.reload + end + + it "allows to delete the category" do + expect(staff_guardian).to be_able_to_delete_category(category) + end + end + + context "when category has a channel" do + it "does not allow to delete the category" do + expect(staff_guardian).not_to be_able_to_delete_category(category) + end + end + end + end + + describe "#can_create_channel_message?" do + context "when user is staff" do + it "returns true if the channel is open" do + channel.update!(status: :open) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns true if the channel is closed" do + channel.update!(status: :closed) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns false if the channel is archived" do + channel.update!(status: :archived) + expect(staff_guardian.can_create_channel_message?(channel)).to eq(false) + end + + context "for direct message channels" do + it "returns true if the channel is open" do + dm_channel.update!(status: :open) + expect(staff_guardian.can_create_channel_message?(dm_channel)).to eq(true) + end + end + end + + context "when user is not staff" do + it "returns true if the channel is open" do + channel.update!(status: :open) + expect(guardian.can_create_channel_message?(channel)).to eq(true) + end + + it "returns false if the channel is closed" do + channel.update!(status: :closed) + expect(guardian.can_create_channel_message?(channel)).to eq(false) + end + + it "returns false if the channel is archived" do + channel.update!(status: :archived) + expect(guardian.can_create_channel_message?(channel)).to eq(false) + end + + context "for direct message channels" do + before { Group.refresh_automatic_groups! } + + it "it still allows the user to message even if they are not in direct_message_enabled_groups because they are not creating the channel" do + SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] + dm_channel.update!(status: :open) + expect(guardian.can_create_channel_message?(dm_channel)).to eq(true) + end + end + end + end +end diff --git a/plugins/chat/spec/lib/message_mover_spec.rb b/plugins/chat/spec/lib/message_mover_spec.rb new file mode 100644 index 00000000000..571bb3076b9 --- /dev/null +++ b/plugins/chat/spec/lib/message_mover_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::MessageMover do + fab!(:acting_user) { Fabricate(:admin, username: "testmovechat") } + fab!(:source_channel) { Fabricate(:category_channel) } + fab!(:destination_channel) { Fabricate(:category_channel) } + + fab!(:message1) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 3.minutes.ago, + message: "the first to be moved", + ) + end + fab!(:message2) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 2.minutes.ago, + message: "message deux @testmovechat", + ) + end + fab!(:message3) do + Fabricate( + :chat_message, + chat_channel: source_channel, + created_at: 1.minute.ago, + message: "the third message", + ) + end + fab!(:message4) { Fabricate(:chat_message, chat_channel: destination_channel) } + fab!(:message5) { Fabricate(:chat_message, chat_channel: destination_channel) } + fab!(:message6) { Fabricate(:chat_message, chat_channel: destination_channel) } + let(:move_message_ids) { [message1.id, message2.id, message3.id] } + + subject do + described_class.new( + acting_user: acting_user, + source_channel: source_channel, + message_ids: move_message_ids, + ) + end + + describe "#move_to_channel" do + def move! + subject.move_to_channel(destination_channel) + end + + it "raises an error if either the source or destination channels are not public (they cannot be DM channels)" do + expect { + described_class.new( + acting_user: acting_user, + source_channel: Fabricate(:dm_channel), + message_ids: move_message_ids, + ).move_to_channel(destination_channel) + }.to raise_error(Chat::MessageMover::InvalidChannel) + expect { + described_class.new( + acting_user: acting_user, + source_channel: source_channel, + message_ids: move_message_ids, + ).move_to_channel(Fabricate(:dm_channel)) + }.to raise_error(Chat::MessageMover::InvalidChannel) + end + + it "raises an error if no messages are found using the message ids" do + other_channel = Fabricate(:chat_channel) + message1.update(chat_channel: other_channel) + message2.update(chat_channel: other_channel) + message3.update(chat_channel: other_channel) + expect { move! }.to raise_error(Chat::MessageMover::NoMessagesFound) + end + + it "deletes the messages from the source channel and sends messagebus delete messages" do + messages = MessageBus.track_publish { move! } + expect(ChatMessage.where(id: move_message_ids)).to eq([]) + deleted_messages = ChatMessage.with_deleted.where(id: move_message_ids).order(:id) + expect(deleted_messages.count).to eq(3) + expect(messages.first.channel).to eq("/chat/#{source_channel.id}") + expect(messages.first.data[:typ]).to eq("bulk_delete") + expect(messages.first.data[:deleted_ids]).to eq(deleted_messages.map(&:id)) + expect(messages.first.data[:deleted_at]).not_to eq(nil) + end + + it "creates a message in the source channel to indicate that the messages have been moved" do + move! + placeholder_message = ChatMessage.where(chat_channel: source_channel).order(:created_at).last + destination_first_moved_message = + ChatMessage.find_by(chat_channel: destination_channel, message: "the first to be moved") + expect(placeholder_message.message).to eq( + I18n.t( + "chat.channel.messages_moved", + count: move_message_ids.length, + acting_username: acting_user.username, + channel_name: destination_channel.title(acting_user), + first_moved_message_url: destination_first_moved_message.url, + ), + ) + end + + it "preserves the order of the messages in the destination channel" do + move! + moved_messages = + ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + expect(moved_messages.map(&:message)).to eq( + ["the first to be moved", "message deux @testmovechat", "the third message"], + ) + end + + it "updates references for reactions, uploads, revisions, mentions, etc." do + reaction = Fabricate(:chat_message_reaction, chat_message: message1) + upload = Fabricate(:chat_upload, chat_message: message1) + mention = Fabricate(:chat_mention, chat_message: message2, user: acting_user) + revision = Fabricate(:chat_message_revision, chat_message: message3) + webhook_event = Fabricate(:chat_webhook_event, chat_message: message3) + move! + + moved_messages = + ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + expect(reaction.reload.chat_message_id).to eq(moved_messages.first.id) + expect(upload.reload.chat_message_id).to eq(moved_messages.first.id) + expect(mention.reload.chat_message_id).to eq(moved_messages.second.id) + expect(revision.reload.chat_message_id).to eq(moved_messages.third.id) + expect(webhook_event.reload.chat_message_id).to eq(moved_messages.third.id) + end + end +end diff --git a/plugins/chat/spec/lib/post_notification_handler_spec.rb b/plugins/chat/spec/lib/post_notification_handler_spec.rb new file mode 100644 index 00000000000..620fe991e0c --- /dev/null +++ b/plugins/chat/spec/lib/post_notification_handler_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::PostNotificationHandler do + let(:acting_user) { Fabricate(:user) } + let(:post) { Fabricate(:post) } + let(:notified_users) { [] } + let(:subject) { Chat::PostNotificationHandler.new(post, notified_users) } + + fab!(:channel) { Fabricate(:category_channel) } + fab!(:message1) do + Fabricate(:chat_message, chat_channel: channel, message: "hey this is the first message :)") + end + fab!(:message2) do + Fabricate( + :chat_message, + chat_channel: channel, + message: "our true enemy. has yet. to reveal himself.", + ) + end + + before { Notification.destroy_all } + + def expect_no_notification + return_val = nil + expect { return_val = subject.handle }.not_to change { Notification.count } + expect(return_val).to eq(false) + end + + def update_post_with_chat_quote(messages) + quote_markdown = + ChatTranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown + post.update!(raw: post.raw + "\n\n" + quote_markdown) + end + + it "does nothing if the post is a whisper" do + post.update(post_type: Post.types[:whisper]) + expect_no_notification + end + + it "does nothing if the topic is deleted" do + post.topic.destroy && post.reload + expect_no_notification + end + + it "does nothing if the topic is a private message" do + post.update(topic: Fabricate(:private_message_topic)) + expect_no_notification + end + + it "sends notifications to all of the quoted users" do + update_post_with_chat_quote([message1, message2]) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + expect( + Notification.where( + user: message2.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + + it "does not send the same chat_quoted notification twice to the same post and user" do + update_post_with_chat_quote([message1, message2]) + subject.handle + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + + it "does not send a notification if the user has got a reply notification to the quoted user for the same post" do + update_post_with_chat_quote([message1, message2]) + Fabricate( + :notification, + notification_type: Notification.types[:replied], + post_number: post.post_number, + topic: post.topic, + user: message1.user, + ) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(0) + end + + context "when some users have already been notified for the post" do + let(:notified_users) { [message1.user] } + + it "does not send notifications to those users" do + update_post_with_chat_quote([message1, message2]) + subject.handle + expect( + Notification.where( + user: message1.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(0) + expect( + Notification.where( + user: message2.user, + notification_type: Notification.types[:chat_quoted], + ).count, + ).to eq(1) + end + end +end diff --git a/plugins/chat/spec/lib/slack_compatibility_spec.rb b/plugins/chat/spec/lib/slack_compatibility_spec.rb new file mode 100644 index 00000000000..1e8c550f085 --- /dev/null +++ b/plugins/chat/spec/lib/slack_compatibility_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::SlackCompatibility do + describe "#process_text" do + it "converts mrkdwn links to regular markdown" do + text = described_class.process_text("this is some text ") + expect(text).to eq("this is some text https://discourse.org") + end + + it "converts mrkdwn links with titles to regular markdown" do + text = + described_class.process_text("this is some text ") + expect(text).to eq("this is some text [Discourse Forums](https://discourse.org)") + end + + it "handles multiple links" do + text = + described_class.process_text( + "this is some text with a second link to ", + ) + expect(text).to eq( + "this is some text [Discourse Forums](https://discourse.org) with a second link to https://discourse.org/team", + ) + end + + it "converts and to our mention format" do + text = described_class.process_text(" this is some important stuff ") + expect(text).to eq("@here this is some important stuff @all") + end + end +end diff --git a/plugins/chat/spec/mailers/user_notifications_spec.rb b/plugins/chat/spec/mailers/user_notifications_spec.rb new file mode 100644 index 00000000000..7eb41de19e1 --- /dev/null +++ b/plugins/chat/spec/mailers/user_notifications_spec.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe UserNotifications do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } + fab!(:user) { Fabricate(:user, group_ids: [chatters_group.id]) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = chatters_group.id + end + + def refresh_auto_groups + Group.refresh_automatic_groups! + user.reload + sender.reload + end + + describe ".chat_summary" do + context "with private channel" do + fab!(:channel) do + refresh_auto_groups + Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user]) + end + + describe "email subject" do + it "includes the sender username in the subject" do + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.direct_message", + count: 1, + email_prefix: SiteSetting.title, + message_title: sender.username, + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(sender.username) + end + + it "only includes the name of the user who sent the message even if the DM has multiple participants" do + another_participant = Fabricate(:user, group_ids: [chatters_group.id]) + Fabricate( + :user_chat_channel_membership_for_dm, + user: another_participant, + chat_channel: channel, + ) + DirectMessageUser.create!( + direct_message_channel: channel.chatable, + user: another_participant, + ) + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.direct_message", + count: 1, + email_prefix: SiteSetting.title, + message_title: sender.username, + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(sender.username) + expect(email.subject).not_to include(another_participant.username) + end + + it "includes both channel titles when there are exactly two with unread messages" do + another_dm_user = Fabricate(:user, group_ids: [chatters_group.id]) + refresh_auto_groups + another_dm_user.reload + another_channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: user, + target_users: [another_dm_user, user], + ) + Fabricate(:chat_message, user: another_dm_user, chat_channel: another_channel) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(sender.username) + expect(email.subject).to include(another_dm_user.username) + end + + it "displays a count when there are more than two DMs with unread messages" do + user = Fabricate(:user, group_ids: [chatters_group.id]) + + 3.times do + sender = Fabricate(:user, group_ids: [chatters_group.id]) + refresh_auto_groups + sender.reload + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [user, sender], + ) + user + .user_chat_channel_memberships + .where(chat_channel_id: channel.id) + .update!(following: true) + + Fabricate(:chat_message, user: sender, chat_channel: channel) + end + + expected_count_text = I18n.t("user_notifications.chat_summary.subject.others", count: 2) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_count_text) + end + + it "returns an email if the user is not following the direct channel" do + user + .user_chat_channel_memberships + .where(chat_channel_id: channel.id) + .update!(following: false) + Fabricate(:chat_message, user: sender, chat_channel: channel) + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + end + end + + context "with public channel" do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, user: sender, chat_channel: channel) } + fab!(:user_membership) do + Fabricate( + :user_chat_channel_membership, + chat_channel: channel, + user: user, + last_read_message_id: chat_message.id - 2, + ) + end + + it "doesn't return an email if there are no unread mentions" do + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + describe "email subject" do + context "with regular mentions" do + before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + + it "includes the sender username in the subject" do + expected_subject = + I18n.t( + "user_notifications.chat_summary.subject.chat_channel", + count: 1, + email_prefix: SiteSetting.title, + message_title: channel.title(user), + ) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to eq(expected_subject) + expect(email.subject).to include(channel.title(user)) + end + + it "includes both channel titles when there are exactly two with unread mentions" do + another_chat_channel = Fabricate(:category_channel, name: "Test channel") + another_chat_message = + Fabricate(:chat_message, user: sender, chat_channel: another_chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: sender, + ) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: user, + last_read_message_id: another_chat_message.id - 2, + ) + Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(channel.title(user)) + expect(email.subject).to include(another_chat_channel.title(user)) + end + + it "displays a count when there are more than two channels with unread mentions" do + 2.times do |n| + another_chat_channel = Fabricate(:category_channel, name: "Test channel #{n}") + another_chat_message = + Fabricate(:chat_message, user: sender, chat_channel: another_chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: sender, + ) + Fabricate( + :user_chat_channel_membership, + chat_channel: another_chat_channel, + user: user, + last_read_message_id: another_chat_message.id - 2, + ) + Fabricate(:chat_mention, user: user, chat_message: another_chat_message) + end + expected_count_text = I18n.t("user_notifications.chat_summary.subject.others", count: 2) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_count_text) + end + end + + context "with both unread DM messages and mentions" do + before do + refresh_auto_groups + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [sender, user], + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: chat_message) + end + + it "always includes the DM second" do + expected_other_text = + I18n.t( + "user_notifications.chat_summary.subject.other_direct_message", + message_title: sender.username, + ) + + email = described_class.chat_summary(user, {}) + + expect(email.subject).to include(expected_other_text) + end + end + end + + describe "When there are mentions" do + before { Fabricate(:chat_mention, user: user, chat_message: chat_message) } + + describe "selecting mentions" do + it "doesn't return an email if the user can't see chat" do + SiteSetting.chat_allowed_groups = "" + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the user can't see any of the included channels" do + channel.chatable.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the user is not following the channel" do + user_membership.update!(following: false) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the membership object doesn't exist" do + user_membership.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the sender was deleted" do + sender.destroy! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email when the user already saw the mention" do + user_membership.update!(last_read_message_id: chat_message.id) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "returns an email when the user haven't read a message yet" do + user_membership.update!(last_read_message_id: nil) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "doesn't return an email when the unread count belongs to a different channel" do + user_membership.update!(last_read_message_id: chat_message.id) + second_channel = Fabricate(:chat_channel) + Fabricate( + :user_chat_channel_membership, + chat_channel: second_channel, + user: user, + last_read_message_id: chat_message.id - 1, + ) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "doesn't return an email if the message was deleted" do + chat_message.trash! + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + + it "returns an email when the user has unread private messages" do + user_membership.update!(last_read_message_id: chat_message.id) + refresh_auto_groups + channel = + Chat::DirectMessageChannelCreator.create!( + acting_user: sender, + target_users: [sender, user], + ) + Fabricate(:chat_message, user: sender, chat_channel: channel) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "returns an email if the user read all the messages included in the previous summary" do + user_membership.update!( + last_read_message_id: chat_message.id, + last_unread_mention_when_emailed_id: chat_message.id, + ) + + new_message = Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: new_message) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + end + + it "doesn't return an email if the mention is older than 1 week" do + chat_message.update!(created_at: 1.5.weeks.ago) + + email = described_class.chat_summary(user, {}) + + expect(email.to).to be_blank + end + end + + describe "mail contents" do + it "returns an email when the user has unread mentions" do + email = described_class.chat_summary(user, {}) + + expect(email.to).to contain_exactly(user.email) + expect(email.html_part.body.to_s).to include(chat_message.cooked_for_excerpt) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("src").value).to eq(sender.small_avatar_url) + expect(user_avatar.attribute("alt").value).to eq(sender.username) + + more_messages_channel_link = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".more-messages-link") + + expect(more_messages_channel_link.attribute("href").value).to eq(chat_message.full_url) + expect(more_messages_channel_link.text).to include( + I18n.t("user_notifications.chat_summary.view_messages", count: 1), + ) + end + + it "displays the sender's name when the site is configured to prioritize it" do + SiteSetting.enable_names = true + SiteSetting.prioritize_username_in_ux = false + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.name) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.name) + end + + it "displays the sender's username when the site is configured to prioritize it" do + SiteSetting.enable_names = true + SiteSetting.prioritize_username_in_ux = true + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "displays the sender's username when names are disabled" do + SiteSetting.enable_names = false + SiteSetting.prioritize_username_in_ux = false + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "displays the sender's username when the site is configured to prioritize it" do + SiteSetting.enable_names = false + SiteSetting.prioritize_username_in_ux = true + + email = described_class.chat_summary(user, {}) + + user_name = Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row span") + expect(user_name.text).to include(sender.username) + + user_avatar = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".message-row img") + + expect(user_avatar.attribute("alt").value).to eq(sender.username) + end + + it "includes a view more link when there are more than two mentions" do + 2.times do + msg = Fabricate(:chat_message, user: sender, chat_channel: channel) + Fabricate(:chat_mention, user: user, chat_message: msg) + end + + email = described_class.chat_summary(user, {}) + more_messages_channel_link = + Nokogiri::HTML5.fragment(email.html_part.body.to_s).css(".more-messages-link") + + expect(more_messages_channel_link.attribute("href").value).to eq(chat_message.full_url) + expect(more_messages_channel_link.text).to include( + I18n.t("user_notifications.chat_summary.view_more", count: 1), + ) + end + + it "doesn't repeat mentions we already sent" do + user_membership.update!( + last_read_message_id: chat_message.id - 1, + last_unread_mention_when_emailed_id: chat_message.id, + ) + + new_message = + Fabricate(:chat_message, user: sender, chat_channel: channel, cooked: "New message") + Fabricate(:chat_mention, user: user, chat_message: new_message) + + email = described_class.chat_summary(user, {}) + body = email.html_part.body.to_s + + expect(body).not_to include(chat_message.cooked_for_excerpt) + expect(body).to include(new_message.cooked_for_excerpt) + end + end + end + end + end +end diff --git a/plugins/chat/spec/models/category_channel_spec.rb b/plugins/chat/spec/models/category_channel_spec.rb new file mode 100644 index 00000000000..ad59b3efe91 --- /dev/null +++ b/plugins/chat/spec/models/category_channel_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +RSpec.describe CategoryChannel do + subject(:channel) { Fabricate.build(:category_channel) } + + it_behaves_like "a chat channel model" + + it { is_expected.to delegate_method(:read_restricted?).to(:category) } + it { is_expected.to delegate_method(:url).to(:chatable).with_prefix } + + describe "#category_channel?" do + it "always returns true" do + expect(channel).to be_a_category_channel + end + end + + describe "#public_channel?" do + it "always returns true" do + expect(channel).to be_a_public_channel + end + end + + describe "#chatable_has_custom_fields?" do + it "always returns true" do + expect(channel).to be_a_chatable_has_custom_fields + end + end + + describe "#direct_message_channel?" do + it "always returns false" do + expect(channel).not_to be_a_direct_message_channel + end + end + + describe "#allowed_user_ids" do + it "always returns nothing" do + expect(channel.allowed_user_ids).to be_nil + end + end + + describe "#allowed_group_ids" do + subject(:allowed_group_ids) { channel.allowed_group_ids } + + context "when channel is public" do + let(:public_category) { Fabricate(:category, read_restricted: false) } + let(:channel) { Fabricate(:category_channel, chatable: public_category) } + + it "returns nothing" do + expect(allowed_group_ids).to be_nil + end + end + + context "when channel is not public" do + let(:staff_groups) { Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values } + let(:group) { Fabricate(:group) } + let(:private_category) { Fabricate(:private_category, group: group) } + let(:channel) { Fabricate(:category_channel, chatable: private_category) } + + it "returns groups with access to the associated category" do + expect(allowed_group_ids).to contain_exactly(*staff_groups, group.id) + end + end + end + + describe "#title" do + subject(:title) { channel.title(nil) } + + before { channel.name = custom_name } + + context "when 'name' is set" do + let(:custom_name) { "a custom name" } + + it "returns the name that has been set on the channel" do + expect(title).to eq(custom_name) + end + end + + context "when 'name' is not set" do + let(:custom_name) { nil } + + it "returns the name from the associated category" do + expect(title).to eq(channel.category.name) + end + end + end +end diff --git a/plugins/chat/spec/models/category_spec.rb b/plugins/chat/spec/models/category_spec.rb new file mode 100644 index 00000000000..15f0495187d --- /dev/null +++ b/plugins/chat/spec/models/category_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Category do + it_behaves_like "a chatable model" do + fab!(:chatable) { Fabricate(:category) } + let(:channel_class) { CategoryChannel } + end + + it { is_expected.to have_one(:category_channel) } + + describe "#cannot_delete_reason" do + subject(:reason) { category.cannot_delete_reason } + + context "when a chat channel is present" do + let(:channel) { Fabricate(:category_channel) } + let(:category) { channel.chatable } + + it "returns a message" do + expect(reason).to match I18n.t("category.cannot_delete.has_chat_channels") + end + end + end +end diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat_message_spec.rb new file mode 100644 index 00000000000..d1a62d7e85a --- /dev/null +++ b/plugins/chat/spec/models/chat_message_spec.rb @@ -0,0 +1,487 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessage do + fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") } + + describe ".cook" do + it "does not support HTML tags" do + cooked = ChatMessage.cook("

test

") + + expect(cooked).to eq("

<h1>test</h1>

") + end + + it "does not support headings" do + cooked = ChatMessage.cook("## heading 2") + + expect(cooked).to eq("

## heading 2

") + end + + it "does not support horizontal rules" do + cooked = ChatMessage.cook("---") + + expect(cooked).to eq("

---

") + end + + it "supports backticks rule" do + cooked = ChatMessage.cook("`test`") + + expect(cooked).to eq("

test

") + end + + it "supports fence rule" do + cooked = ChatMessage.cook(<<~RAW) + ``` + something = test + ``` + RAW + + expect(cooked).to eq(<<~COOKED.chomp) +
something = test
+      
+ COOKED + end + + it "supports fence rule with language support" do + cooked = ChatMessage.cook(<<~RAW) + ```ruby + Widget.triangulate(argument: "no u") + ``` + RAW + + expect(cooked).to eq(<<~COOKED.chomp) +
Widget.triangulate(argument: "no u")
+      
+ COOKED + end + + it "supports code rule" do + cooked = ChatMessage.cook(" something = test") + + expect(cooked).to eq("
something = test\n
") + end + + it "supports blockquote rule" do + cooked = ChatMessage.cook("> a quote") + + expect(cooked).to eq("
\n

a quote

\n
") + end + + it "supports quote bbcode" do + topic = Fabricate(:topic, title: "Some quotable topic") + post = Fabricate(:post, topic: topic) + SiteSetting.external_system_avatars_enabled = false + avatar_src = + "//test.localhost#{User.system_avatar_template(post.user.username).gsub("{size}", "40")}" + + cooked = ChatMessage.cook(<<~RAW) + [quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"] + Mark me...this will go down in history. + [/quote] + RAW + + expect(cooked).to eq(<<~COOKED.chomp) + + COOKED + end + + it "supports chat quote bbcode" do + chat_channel = Fabricate(:category_channel, name: "testchannel") + user = Fabricate(:user, username: "chatbbcodeuser") + user2 = Fabricate(:user, username: "otherbbcodeuser") + avatar_src = + "//test.localhost#{User.system_avatar_template(user.username).gsub("{size}", "40")}" + avatar_src2 = + "//test.localhost#{User.system_avatar_template(user2.username).gsub("{size}", "40")}" + msg1 = + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "this is the first message", + user: user, + ) + msg2 = + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "and another cool one", + user: user2, + ) + other_messages_to_quote = [msg1, msg2] + cooked = + ChatMessage.cook( + ChatTranscriptService.new( + chat_channel, + Fabricate(:user), + messages_or_ids: other_messages_to_quote.map(&:id), + ).generate_markdown, + ) + + expect(cooked).to eq(<<~COOKED.chomp) +
+
+ Originally sent in testchannel +
+
+
+ +
+
+ chatbbcodeuser
+
+ +
+
+
+

this is the first message

+
+
+
+
+
+ +
+
+ otherbbcodeuser
+
+ +
+
+
+

and another cool one

+
+
+ COOKED + end + + it "supports strikethrough rule" do + cooked = ChatMessage.cook("~~test~~") + + expect(cooked).to eq("

test

") + end + + it "supports emphasis rule" do + cooked = ChatMessage.cook("**bold**") + + expect(cooked).to eq("

bold

") + end + + it "supports link markdown rule" do + chat_message = Fabricate(:chat_message, message: "[test link](https://www.example.com)") + + expect(chat_message.cooked).to eq( + "

test link

", + ) + end + + it "supports table markdown plugin" do + cooked = ChatMessage.cook(<<~RAW) + | Command | Description | + | --- | --- | + | git status | List all new or modified files | + RAW + + expected = <<~COOKED +
+ + + + + + + + + + + + + +
CommandDescription
git statusList all new or modified files
+
+ COOKED + + expect(cooked).to eq(expected.chomp) + end + + it "supports onebox markdown plugin" do + cooked = ChatMessage.cook("https://www.example.com") + + expect(cooked).to eq( + "

https://www.example.com

", + ) + end + + it "supports emoji plugin" do + cooked = ChatMessage.cook(":grin:") + + expect(cooked).to eq( + "

\":grin:\"

", + ) + end + + it "supports mentions plugin" do + cooked = ChatMessage.cook("@mention") + + expect(cooked).to eq("

@mention

") + end + + it "supports category-hashtag plugin" do + category = Fabricate(:category) + + cooked = ChatMessage.cook("##{category.slug}") + + expect(cooked).to eq( + "

##{category.slug}

", + ) + end + + it "supports censored plugin" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) + + cooked = ChatMessage.cook(watched_word.word) + + expect(cooked).to eq("

■■■■■

") + end + + it "includes links in pretty text excerpt if the raw message is a single link and the PrettyText excerpt is blank" do + message = + Fabricate.build( + :chat_message, + message: "https://twitter.com/EffinBirds/status/1518743508378697729", + ) + expect(message.excerpt).to eq("https://twitter.com/EffinBirds/status/1518743508378697729") + message = + Fabricate.build( + :chat_message, + message: "https://twitter.com/EffinBirds/status/1518743508378697729", + cooked: <<~COOKED, + \n + COOKED + ) + expect(message.excerpt).to eq("https://twitter.com/EffinBirds/status/1518743508378697729") + message = + Fabricate.build( + :chat_message, + message: + "wow check out these birbs https://twitter.com/EffinBirds/status/1518743508378697729", + ) + expect(message.excerpt).to eq( + "wow check out these birbs https://twitter.com/Effi…", + ) + end + + it "returns an empty string if PrettyText.excerpt returns empty string" do + message = Fabricate(:chat_message, message: <<~MSG) + [quote="martin, post:30, topic:3179, full:true"] + This is a real **quote** topic with some *markdown* in it I can quote. + [/quote] + MSG + expect(message.excerpt).to eq("") + end + + it "excerpts upload file name if message is empty" do + gif = + Fabricate(:upload, original_filename: "cat.gif", width: 400, height: 300, extension: "gif") + message = Fabricate(:chat_message, message: "") + ChatUpload.create(chat_message: message, upload: gif) + + expect(message.excerpt).to eq "cat.gif" + end + + it "supports autolink with <>" do + cooked = ChatMessage.cook("") + + expect(cooked).to eq( + "

https://github.com/discourse/discourse-chat/pull/468

", + ) + end + + it "supports lists" do + cooked = ChatMessage.cook(<<~MSG) + wow look it's a list + + * item 1 + * item 2 + MSG + + expect(cooked).to eq(<<~HTML.chomp) +

wow look it's a list

+
    +
  • item 1
  • +
  • item 2
  • +
+ HTML + end + + it "supports inline emoji" do + cooked = ChatMessage.cook(":D") + expect(cooked).to eq(<<~HTML.chomp) +

:smiley:

+ HTML + end + + it "supports emoji shortcuts" do + cooked = ChatMessage.cook("this is a replace test :P :|") + expect(cooked).to eq(<<~HTML.chomp) +

this is a replace test :stuck_out_tongue: :expressionless:

+ HTML + end + + it "supports spoilers" do + if SiteSetting.respond_to?(:spoiler_enabled) && SiteSetting.spoiler_enabled + cooked = ChatMessage.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") + + expect(cooked).to eq( + "
\n

the planet of the apes was earth all along

\n
", + ) + end + end + + context "when unicode usernames are enabled" do + before { SiteSetting.unicode_usernames = true } + + it "cooks unicode mentions" do + user = Fabricate(:unicode_user) + cooked = ChatMessage.cook("

@#{user.username}

") + + expect(cooked).to eq("

<h1>@#{user.username}</h1>

") + end + end + end + + describe ".to_markdown" do + it "renders the message without uploads" do + expect(message.to_markdown).to eq("hey friend, what's up?!") + end + + it "renders the message with uploads" do + image = + Fabricate( + :upload, + original_filename: "test_image.jpg", + width: 400, + height: 300, + extension: "jpg", + ) + image2 = + Fabricate(:upload, original_filename: "meme.jpg", width: 10, height: 10, extension: "jpg") + ChatUpload.create(chat_message: message, upload: image) + ChatUpload.create(chat_message: message, upload: image2) + expect(message.to_markdown).to eq(<<~MSG.chomp) + hey friend, what's up?! + + ![test_image.jpg|400x300](#{image.short_url}) + ![meme.jpg|10x10](#{image2.short_url}) + MSG + end + end + + describe ".push_notification_excerpt" do + it "truncates to 400 characters" do + message = ChatMessage.new(message: "Hello, World!" * 40) + expect(message.push_notification_excerpt.size).to eq(400) + end + + it "encodes emojis" do + message = ChatMessage.new(message: ":grinning:") + expect(message.push_notification_excerpt).to eq("😀") + end + end + + describe "blocking duplicate messages" do + fab!(:channel) { Fabricate(:chat_channel, user_count: 10) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + before { SiteSetting.chat_duplicate_message_sensitivity = 1 } + + it "blocks duplicate messages for the message, channel user, and message age requirements" do + Fabricate(:chat_message, message: "this is duplicate", chat_channel: channel, user: user1) + message = ChatMessage.new(message: "this is duplicate", chat_channel: channel, user: user2) + message.validate_message(has_uploads: false) + expect(message.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message")) + end + end + + describe "#destroy" do + it "nullify messages with in_reply_to_id to this destroyed message" do + message_1 = Fabricate(:chat_message) + message_2 = Fabricate(:chat_message, in_reply_to_id: message_1.id) + message_3 = Fabricate(:chat_message, in_reply_to_id: message_2.id) + + expect(message_2.in_reply_to_id).to eq(message_1.id) + + message_1.destroy! + + expect(message_2.reload.in_reply_to_id).to be_nil + expect(message_3.reload.in_reply_to_id).to eq(message_2.id) + end + + it "destroys chat_message_revisions" do + message_1 = Fabricate(:chat_message) + revision_1 = Fabricate(:chat_message_revision, chat_message: message_1) + + message_1.destroy! + + expect { revision_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_message_reactions" do + message_1 = Fabricate(:chat_message) + reaction_1 = Fabricate(:chat_message_reaction, chat_message: message_1) + + message_1.destroy! + + expect { reaction_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_mention" do + message_1 = Fabricate(:chat_message) + mention_1 = Fabricate(:chat_mention, chat_message: message_1) + + message_1.destroy! + + expect { mention_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_webhook_event" do + message_1 = Fabricate(:chat_message) + webhook_1 = Fabricate(:chat_webhook_event, chat_message: message_1) + + message_1.destroy! + + expect { webhook_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "destroys chat_uploads" do + message_1 = Fabricate(:chat_message) + chat_upload_1 = Fabricate(:chat_upload, chat_message: message_1) + + message_1.destroy! + + expect { chat_upload_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + describe "bookmarks" do + before { Bookmark.register_bookmarkable(ChatMessageBookmarkable) } + + it "destroys bookmarks" do + message_1 = Fabricate(:chat_message) + bookmark_1 = Fabricate(:bookmark, bookmarkable: message_1) + + message_1.destroy! + + expect { bookmark_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/plugins/chat/spec/models/d_m_channel_spec.rb b/plugins/chat/spec/models/d_m_channel_spec.rb new file mode 100644 index 00000000000..519b9555fbb --- /dev/null +++ b/plugins/chat/spec/models/d_m_channel_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe DMChannel do + subject(:channel) { Fabricate.build(:dm_channel) } + + it_behaves_like "a chat channel model" + + it { is_expected.to delegate_method(:allowed_user_ids).to(:direct_message_channel).as(:user_ids) } + + describe "#category_channel?" do + it "always returns false" do + expect(channel).not_to be_a_category_channel + end + end + + describe "#public_channel?" do + it "always returns false" do + expect(channel).not_to be_a_public_channel + end + end + + describe "#chatable_has_custom_fields?" do + it "always returns false" do + expect(channel).not_to be_a_chatable_has_custom_fields + end + end + + describe "#direct_message_channel?" do + it "always returns true" do + expect(channel).to be_a_direct_message_channel + end + end + + describe "#read_restricted?" do + it "always returns true" do + expect(channel).to be_read_restricted + end + end + + describe "#allowed_group_ids" do + it "always returns nothing" do + expect(channel.allowed_group_ids).to be_nil + end + end + + describe "#chatable_url" do + it "always returns nothing" do + expect(channel.chatable_url).to be_nil + end + end + + describe "#title" do + subject(:title) { channel.title(user) } + + let(:user) { stub } + let(:direct_message_channel) { channel.direct_message_channel } + + it "delegates to direct_message_channel" do + direct_message_channel + .expects(:chat_channel_title_for_user) + .with(channel, user) + .returns("something") + expect(title).to eq("something") + end + end +end diff --git a/plugins/chat/spec/models/deleted_chat_user_spec.rb b/plugins/chat/spec/models/deleted_chat_user_spec.rb new file mode 100644 index 00000000000..92617c58f73 --- /dev/null +++ b/plugins/chat/spec/models/deleted_chat_user_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DeletedChatUser do + describe "#username" do + it "returns a default username" do + expect(subject.username).to eq(I18n.t("chat.deleted_chat_username")) + end + end + + describe "#avatar_template" do + it "returns a default path" do + expect(subject.avatar_template).to eq( + "/plugins/chat/images/deleted-chat-user-avatar.png", + ) + end + end +end diff --git a/plugins/chat/spec/models/direct_message_channel_spec.rb b/plugins/chat/spec/models/direct_message_channel_spec.rb new file mode 100644 index 00000000000..eaffb494893 --- /dev/null +++ b/plugins/chat/spec/models/direct_message_channel_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DirectMessageChannel do + fab!(:user1) { Fabricate(:user, username: "chatdmfellow1") } + fab!(:user2) { Fabricate(:user, username: "chatdmuser") } + fab!(:chat_channel) { Fabricate(:chat_channel) } + + it_behaves_like "a chatable model" do + fab!(:chatable) { Fabricate(:direct_message_channel) } + let(:channel_class) { DMChannel } + end + + describe "#chat_channel_title_for_user" do + it "returns a nicely formatted name if it's more than one user" do + user3 = Fabricate.build(:user, username: "chatdmregent") + direct_message_channel = Fabricate(:direct_message_channel, users: [user1, user2, user3]) + + expect(direct_message_channel.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t( + "chat.channel.dm_title.multi_user", + users: [user3, user2].map { |u| "@#{u.username}" }.join(", "), + ), + ) + end + + it "returns a nicely formatted truncated name if it's more than 5 users" do + user3 = Fabricate.build(:user, username: "chatdmregent") + + users = [user1, user2, user3].concat( + 5.times.map.with_index { |i| Fabricate(:user, username: "chatdmuser#{i}") }, + ) + direct_message_channel = Fabricate(:direct_message_channel, users: users) + + expect(direct_message_channel.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + users: users[1..5].sort_by(&:username).map { |u| "@#{u.username}" }.join(", "), + leftover: 2, + ), + ) + end + + it "returns the other user's username if it's a dm to that user" do + direct_message_channel = Fabricate(:direct_message_channel, users: [user1, user2]) + + expect(direct_message_channel.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t("chat.channel.dm_title.single_user", user: "@#{user2.username}"), + ) + end + + it "returns the current user's username if it's a dm to self" do + direct_message_channel = Fabricate(:direct_message_channel, users: [user1]) + + expect(direct_message_channel.chat_channel_title_for_user(chat_channel, user1)).to eq( + I18n.t("chat.channel.dm_title.single_user", user: "@#{user1.username}"), + ) + end + + context "when user is deleted" do + it "returns a placeholder username" do + direct_message_channel = Fabricate(:direct_message_channel, users: [user1, user2]) + user2.destroy! + direct_message_channel.reload + + expect(direct_message_channel.chat_channel_title_for_user(chat_channel, user1)).to eq( + "@#{I18n.t("chat.deleted_chat_username")}", + ) + end + end + end +end diff --git a/plugins/chat/spec/models/reviewable_chat_message_spec.rb b/plugins/chat/spec/models/reviewable_chat_message_spec.rb new file mode 100644 index 00000000000..b35b2b572be --- /dev/null +++ b/plugins/chat/spec/models/reviewable_chat_message_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ReviewableChatMessage, type: :model do + fab!(:moderator) { Fabricate(:moderator) } + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel) { Fabricate(:chat_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + fab!(:reviewable) do + Fabricate(:reviewable_chat_message, target: chat_message, created_by: moderator) + end + + it "agree_and_keep agrees with the flag and doesn't delete the message" do + reviewable.perform(moderator, :agree_and_keep_message) + + expect(reviewable).to be_approved + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "agree_and_delete agrees with the flag and deletes the message" do + chat_message_id = chat_message.id + reviewable.perform(moderator, :agree_and_delete) + + expect(reviewable).to be_approved + expect(ChatMessage.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present + end + + it "agree_and_restore agrees with the flag and restores the message" do + chat_message.trash!(user) + reviewable.perform(moderator, :agree_and_restore) + + expect(reviewable).to be_approved + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "perform_disagree disagrees with the flag and does nothing" do + reviewable.perform(moderator, :disagree) + + expect(reviewable).to be_rejected + expect(chat_message.reload.deleted_at).not_to be_present + end + + it "perform_disagree_and_restore disagrees with the flag and does nothing" do + chat_message.trash!(user) + reviewable.perform(moderator, :disagree_and_restore) + + expect(reviewable).to be_rejected + expect(chat_message.reload.deleted_at).to be_present + end + + it "perform_ignore ignores the flag and does nothing" do + reviewable.perform(moderator, :ignore) + + expect(reviewable).to be_ignored + expect(chat_message.reload.deleted_at).not_to be_present + end +end diff --git a/plugins/chat/spec/models/user_spec.rb b/plugins/chat/spec/models/user_spec.rb new file mode 100644 index 00000000000..9d02f0701d1 --- /dev/null +++ b/plugins/chat/spec/models/user_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe User do + it { is_expected.to have_many(:user_chat_channel_memberships).dependent(:destroy) } + it { is_expected.to have_many(:chat_message_reactions).dependent(:destroy) } + it { is_expected.to have_many(:chat_mentions) } +end diff --git a/plugins/chat/spec/plugin_spec.rb b/plugins/chat/spec/plugin_spec.rb new file mode 100644 index 00000000000..bfb5794d42c --- /dev/null +++ b/plugins/chat/spec/plugin_spec.rb @@ -0,0 +1,432 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat do + before do + SiteSetting.clean_up_uploads = true + SiteSetting.clean_orphan_uploads_grace_period_hours = 1 + Jobs::CleanUpUploads.new.reset_last_cleanup! + SiteSetting.chat_enabled = true + end + + describe "register_upload_unused" do + fab!(:chat_channel) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:user) { Fabricate(:user) } + fab!(:upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world!", + upload_ids: [upload.id], + ) + end + + it "marks uploads with ChatUpload in use" do + unused_upload + + expect { Jobs::CleanUpUploads.new.execute({}) }.to change { Upload.count }.by(-1) + expect(Upload.exists?(id: upload.id)).to eq(true) + expect(Upload.exists?(id: unused_upload.id)).to eq(false) + end + end + + describe "register_upload_in_use" do + fab!(:chat_channel) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:user) { Fabricate(:user) } + fab!(:message_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:draft_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world! #{message_upload.sha1}", + upload_ids: [], + ) + end + + let!(:draft_message) do + ChatDraft.create!( + user: user, + chat_channel: chat_channel, + data: + "{\"value\":\"hello world \",\"uploads\":[\"#{draft_upload.sha1}\"],\"replyToMsg\":null}", + ) + end + + it "marks uploads with ChatUpload in use" do + draft_upload + unused_upload + + expect { Jobs::CleanUpUploads.new.execute({}) }.to change { Upload.count }.by(-1) + expect(Upload.exists?(id: message_upload.id)).to eq(true) + expect(Upload.exists?(id: draft_upload.id)).to eq(true) + expect(Upload.exists?(id: unused_upload.id)).to eq(false) + end + end + + describe "user card serializer extension #can_chat_user" do + fab!(:target_user) { Fabricate(:user) } + let!(:user) { Fabricate(:user) } + let!(:guardian) { Guardian.new(user) } + let(:serializer) { UserCardSerializer.new(target_user, scope: guardian) } + fab!(:group) { Fabricate(:group) } + + context "when chat enabled" do + before { SiteSetting.chat_enabled = true } + + it "returns true if the target user and the guardian user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: target_user, group: group) + GroupUser.create(user: user, group: group) + expect(serializer.can_chat_user).to eq(true) + end + + it "returns false if the target user but not the guardian user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: target_user, group: group) + expect(serializer.can_chat_user).to eq(false) + end + + it "returns false if the guardian user but not the target user is in the Chat.allowed_group_ids" do + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + expect(serializer.can_chat_user).to eq(false) + end + + context "when guardian user is same as target user" do + let!(:guardian) { Guardian.new(target_user) } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + + context "when guardian user is anon" do + let!(:guardian) { Guardian.new } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + end + + context "when chat not enabled" do + before { SiteSetting.chat_enabled = false } + + it "returns false" do + expect(serializer.can_chat_user).to eq(false) + end + end + end + + describe "chat oneboxes" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user, active: true) } + fab!(:user_2) { Fabricate(:user, active: false) } + fab!(:user_3) { Fabricate(:user, staged: true) } + fab!(:user_4) { Fabricate(:user, suspended_till: 3.weeks.from_now) } + + let!(:chat_message) do + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + in_reply_to_id: nil, + content: "Hello world!", + upload_ids: [], + ).chat_message + end + + let(:chat_url) { "#{Discourse.base_url}/chat/channel/#{chat_channel.id}" } + + context "when inline" do + it "renders channel" do + results = InlineOneboxer.new([chat_url], skip_cache: true).process + expect(results).to be_present + expect(results[0][:url]).to eq(chat_url) + expect(results[0][:title]).to eq("Chat ##{chat_channel.name}") + end + + it "renders messages" do + results = + InlineOneboxer.new(["#{chat_url}?messageId=#{chat_message.id}"], skip_cache: true).process + expect(results).to be_present + expect(results[0][:url]).to eq("#{chat_url}?messageId=#{chat_message.id}") + expect(results[0][:title]).to eq( + "Message ##{chat_message.id} by #{chat_message.user.username} – ##{chat_channel.name}", + ) + end + end + + context "when regular" do + it "renders channel, excluding inactive, staged, and suspended users" do + user.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) + Jobs::UpdateUserCountsForChatChannels.new.execute({}) + + expect(Oneboxer.preview(chat_url)).to match_html <<~HTML + + + HTML + end + + it "renders messages" do + expect(Oneboxer.preview("#{chat_url}?messageId=#{chat_message.id}")).to match_html <<~HTML +
+ +

Hello world!

+
+ HTML + end + end + end + + describe "auto-joining users to a channel" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } + let!(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) } + + before { Jobs.run_immediately! } + + def assert_user_following_state(user, channel, following:) + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + + following ? (expect(membership.following).to eq(true)) : (expect(membership).to be_nil) + end + + describe "when a user is added to a group with access to a channel through a category" do + let!(:category) { Fabricate(:private_category, group: chatters_group) } + + it "joins the user to the channel if auto-join is enabled" do + chatters_group.add(user) + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if auto-join is disabled" do + channel.update!(auto_join_users: false) + + assert_user_following_state(user, channel, following: false) + end + end + + describe "when a user is created" do + fab!(:category) { Fabricate(:category) } + let(:user) { Fabricate(:user, last_seen_at: nil, first_seen_at: nil) } + + it "queues a job to auto-join the user the first time they log in" do + user.update_last_seen! + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if it's not the first time we see the user" do + user.update!(first_seen_at: 2.minute.ago) + user.update_last_seen! + + assert_user_following_state(user, channel, following: false) + end + + it "does nothing if auto-join is disabled" do + channel.update!(auto_join_users: false) + + user.update_last_seen! + + assert_user_following_state(user, channel, following: false) + end + end + + describe "when category permissions change" do + fab!(:category) { Fabricate(:category) } + + let(:chatters_group_permission) do + { chatters_group.name => CategoryGroup.permission_types[:full] } + end + + describe "given permissions to a new group" do + it "adds the user to the channel" do + chatters_group.add(user) + + category.update!(permissions: chatters_group_permission) + + assert_user_following_state(user, channel, following: true) + end + + it "does nothing if there is no channel for the category" do + another_category = Fabricate(:category) + + another_category.update!(permissions: chatters_group_permission) + + assert_user_following_state(user, channel, following: false) + end + end + end + end + + describe "secure media compatibility" do + it "disables chat uploads if secure media changes from disabled to enabled" do + enable_secure_uploads + expect(SiteSetting.chat_allow_uploads).to eq(false) + last_history = UserHistory.last + expect(last_history.action).to eq(UserHistory.actions[:change_site_setting]) + expect(last_history.previous_value).to eq("true") + expect(last_history.new_value).to eq("false") + expect(last_history.subject).to eq("chat_allow_uploads") + expect(last_history.context).to eq("Disabled because secure_uploads is enabled") + end + + it "does not disable chat uploads if the allow_unsecure_chat_uploads global setting is set" do + global_setting :allow_unsecure_chat_uploads, true + expect { enable_secure_uploads }.not_to change { UserHistory.count } + expect(SiteSetting.chat_allow_uploads).to eq(true) + end + end + + describe "current_user_serializer#chat_channels" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:user) { Fabricate(:user) } + + let(:serializer) { CurrentUserSerializer.new(user, scope: Guardian.new(user)) } + + it "returns the global presence channel state" do + expect(serializer.chat_channels[:global_presence_channel_state]).to be_present + end + + context "when no channels exist" do + it "returns an empty array" do + expect(serializer.chat_channels[:direct_message_channels]).to eq([]) + expect(serializer.chat_channels[:public_channels]).to eq([]) + end + end + + context "when followed public channels exist" do + fab!(:user_2) { Fabricate(:user) } + fab!(:channel) do + Fabricate( + :chat_channel, + chatable: Fabricate(:direct_message_channel, users: [user, user_2]), + ) + end + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + Fabricate( + :chat_channel, + chatable: Fabricate(:direct_message_channel, users: [user, user_2]), + ) + end + + it "returns them" do + expect(serializer.chat_channels[:public_channels]).to eq([]) + expect(serializer.chat_channels[:direct_message_channels].count).to eq(1) + expect(serializer.chat_channels[:direct_message_channels][0].id).to eq(channel.id) + end + end + + context "when followed direct message channels exist" do + fab!(:channel) { Fabricate(:chat_channel) } + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + Fabricate(:chat_channel) + end + + it "returns them" do + expect(serializer.chat_channels[:direct_message_channels]).to eq([]) + expect(serializer.chat_channels[:public_channels].count).to eq(1) + expect(serializer.chat_channels[:public_channels][0].id).to eq(channel.id) + end + end + end + + describe "current_user_serializer#has_joinable_public_channels" do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + fab!(:user) { Fabricate(:user) } + let(:serializer) { CurrentUserSerializer.new(user, scope: Guardian.new(user)) } + + context "when no channels exist" do + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when no joinable channel exist" do + fab!(:channel) { Fabricate(:chat_channel) } + + before do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: channel, following: true) + end + + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when no public channel exist" do + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) } + + it "returns false" do + expect(serializer.has_joinable_public_channels).to eq(false) + end + end + + context "when a joinable channel exists" do + fab!(:channel) { Fabricate(:chat_channel) } + + it "returns true" do + expect(serializer.has_joinable_public_channels).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb new file mode 100644 index 00000000000..cb8a681626b --- /dev/null +++ b/plugins/chat/spec/queries/chat_channel_memberships_query_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatChannelMembershipsQuery do + fab!(:user_1) { Fabricate(:user, username: "Aline", name: "Boetie") } + fab!(:user_2) { Fabricate(:user, username: "Bertrand", name: "Arlan") } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + context "when chatable exists" do + context "when chatable is public" do + fab!(:channel_1) { Fabricate(:category_channel) } + + context "when no memberships exists" do + it "returns an empty array" do + expect(described_class.call(channel_1)).to eq([]) + end + end + + context "when memberships exist" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + it "returns the memberships" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) + end + end + end + + context "when chatable is restricted" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: chatters_group) } + fab!(:channel_1) { Fabricate(:category_channel, chatable: private_category) } + + context "when user is in group" do + before { chatters_group.add(user_1) } + + context "when membership exists" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + end + + it "lists the user" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to include(user_1.id) + end + + it "returns only one membership if user is in multiple allowed groups" do + another_group = Fabricate(:group) + another_group.add(user_1) + private_category.category_groups.create!( + group_id: another_group.id, + permission_type: CategoryGroup.permission_types[:full], + ) + + expect(described_class.call(channel_1).pluck(:user_id)).to contain_exactly(user_1.id) + end + + it "returns the membership if the user still has access through a staff group" do + chatters_group.remove(user_1) + Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(user_1) + + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to include(user_1.id) + end + end + + context "when membership doesn’t exist" do + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to be_empty + end + end + end + + context "when user is not in group" do + context "when membership exists" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + end + + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships).to be_empty + end + end + + context "when membership doesn’t exist" do + it "doesn’t list the user" do + memberships = described_class.call(channel_1) + + expect(memberships).to be_empty + end + end + end + end + + context "when chatable is direct channel" do + fab!(:chatable_1) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } + fab!(:channel_1) { Fabricate(:dm_channel, chatable: chatable_1) } + + context "when no memberships exists" do + it "returns an empty array" do + expect(described_class.call(channel_1)).to eq([]) + end + end + + context "when memberships exist" do + before do + UserChatChannelMembership.create!( + user: user_1, + chat_channel: channel_1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + UserChatChannelMembership.create!( + user: user_2, + chat_channel: channel_1, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + end + + it "returns the memberships" do + memberships = described_class.call(channel_1) + + expect(memberships.pluck(:user_id)).to contain_exactly(user_1.id, user_2.id) + end + end + end + + describe "pagination" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + describe "offset param" do + it "offsets the results" do + memberships = described_class.call(channel_1, offset: 1) + + expect(memberships.length).to eq(1) + end + end + + describe "limit param" do + it "limits the results" do + memberships = described_class.call(channel_1, limit: 1) + + expect(memberships.length).to eq(1) + end + end + end + + describe "username param" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + it "filters the results" do + memberships = described_class.call(channel_1, username: user_1.username) + + expect(memberships.length).to eq(1) + expect(memberships[0].user).to eq(user_1) + end + end + + describe "memberships order" do + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + end + + context "when prioritizes username in ux is enabled" do + before { SiteSetting.prioritize_username_in_ux = true } + + it "is using ascending order on username" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_1) + expect(memberships[1].user).to eq(user_2) + end + end + + context "when prioritize username in ux is disabled" do + before { SiteSetting.prioritize_username_in_ux = false } + + it "is using ascending order on name" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_2) + expect(memberships[1].user).to eq(user_1) + end + + context "when enable names is disabled" do + before { SiteSetting.enable_names = false } + + it "is using ascending order on username" do + memberships = described_class.call(channel_1) + + expect(memberships[0].user).to eq(user_1) + expect(memberships[1].user).to eq(user_2) + end + end + end + end + end + + context "when user is staged" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:staged_user) { Fabricate(:staged) } + + before do + UserChatChannelMembership.create(user: staged_user, chat_channel: channel_1, following: true) + end + + it "doesn’t list staged users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end + + context "when user is suspended" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:suspended_user) do + Fabricate(:user, suspended_at: Time.now, suspended_till: 5.days.from_now) + end + + before do + UserChatChannelMembership.create( + user: suspended_user, + chat_channel: channel_1, + following: true, + ) + end + + it "doesn’t list suspended users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end + + context "when user is inactive" do + fab!(:channel_1) { Fabricate(:category_channel) } + fab!(:inactive_user) { Fabricate(:inactive_user) } + + before do + UserChatChannelMembership.create( + user: inactive_user, + chat_channel: channel_1, + following: true, + ) + end + + it "doesn’t list inactive users" do + memberships = described_class.call(channel_1) + expect(memberships).to be_blank + end + end +end diff --git a/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb b/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb new file mode 100644 index 00000000000..8d9a8701407 --- /dev/null +++ b/plugins/chat/spec/requests/admin/admin_incoming_chat_webhooks_controller_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::AdminIncomingChatWebhooksController do + fab!(:admin) { Fabricate(:admin) } + fab!(:user) { Fabricate(:user) } + fab!(:chat_channel1) { Fabricate(:category_channel) } + fab!(:chat_channel2) { Fabricate(:category_channel) } + + before { SiteSetting.chat_enabled = true } + + describe "#index" do + fab!(:existing1) { Fabricate(:incoming_chat_webhook) } + fab!(:existing2) { Fabricate(:incoming_chat_webhook) } + + it "blocks non-admin" do + sign_in(user) + get "/admin/plugins/chat.json" + expect(response.status).to eq(404) + end + + it "Returns chat_channels and incoming_chat_webhooks for admin" do + sign_in(admin) + get "/admin/plugins/chat.json" + expect(response.status).to eq(200) + expect( + response.parsed_body["incoming_chat_webhooks"].map { |webhook| webhook["id"] }, + ).to match_array([existing1.id, existing2.id]) + end + end + + describe "#create" do + let(:attrs) { { name: "Test1", chat_channel_id: chat_channel1.id } } + + it "blocks non-admin" do + sign_in(user) + post "/admin/plugins/chat/hooks.json", params: attrs + expect(response.status).to eq(404) + end + + it "errors when name isn't present" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", params: { chat_channel_id: chat_channel1.id } + expect(response.status).to eq(400) + end + + it "errors when chat_channel ID isn't present" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", params: { name: "test1a" } + expect(response.status).to eq(400) + end + + it "errors when chat_channel isn't valid" do + sign_in(admin) + post "/admin/plugins/chat/hooks.json", + params: { + name: "test1a", + chat_channel_id: ChatChannel.last.id + 1, + } + expect(response.status).to eq(404) + end + + it "creates a new incoming_chat_webhook record" do + sign_in(admin) + expect { post "/admin/plugins/chat/hooks.json", params: attrs }.to change { + IncomingChatWebhook.count + }.by(1) + expect(response.parsed_body["name"]).to eq(attrs[:name]) + expect(response.parsed_body["chat_channel"]["id"]).to eq(attrs[:chat_channel_id]) + expect(response.parsed_body["url"]).not_to be_nil + end + end + + describe "#update" do + fab!(:existing) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel1) } + let(:attrs) do + { + name: "update test", + chat_channel_id: chat_channel2.id, + emoji: ":slight_smile:", + description: "It does stuff!", + username: "beep boop", + } + end + + it "errors for non-admin" do + sign_in(user) + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: attrs + expect(response.status).to eq(404) + end + + it "errors when name or chat_channel_id aren't present" do + sign_in(admin) + invalid_attrs = attrs + + invalid_attrs[:name] = nil + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: invalid_attrs + expect(response.status).to eq(400) + + invalid_attrs[:name] = "woopsers" + invalid_attrs[:chat_channel_id] = nil + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: invalid_attrs + expect(response.status).to eq(400) + end + + it "updates existing incoming_chat_webhook records" do + sign_in(admin) + put "/admin/plugins/chat/hooks/#{existing.id}.json", params: attrs + expect(response.status).to eq(200) + existing.reload + expect(existing.name).to eq(attrs[:name]) + expect(existing.description).to eq(attrs[:description]) + expect(existing.emoji).to eq(attrs[:emoji]) + expect(existing.chat_channel_id).to eq(attrs[:chat_channel_id]) + expect(existing.username).to eq(attrs[:username]) + end + end + + describe "#update" do + fab!(:existing) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel1) } + + it "errors for non-staff" do + sign_in(user) + delete "/admin/plugins/chat/hooks/#{existing.id}.json" + expect(response.status).to eq(404) + end + + it "destroys incoming_chat_webhook records" do + sign_in(admin) + expect { delete "/admin/plugins/chat/hooks/#{existing.id}.json" }.to change { + IncomingChatWebhook.count + }.by(-1) + end + end +end diff --git a/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb b/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb new file mode 100644 index 00000000000..2c8b0090b40 --- /dev/null +++ b/plugins/chat/spec/requests/api/category_chatables_controller_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::CategoryChatablesController do + describe "#access_by_category" do + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + + context "when signed in as an admin" do + fab!(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + it "returns a list with the group names that could access a chat channel" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group.name}") + expect(response.parsed_body["members_count"]).to eq(0) + expect(response.parsed_body["private"]).to eq(true) + end + + it "doesn't return group names from other categories" do + a_member = Fabricate(:user) + group_2 = Fabricate(:group) + group_2.add(a_member) + category_2 = Fabricate(:private_category, group: group_2) + + get "/chat/api/category-chatables/#{category_2.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group_2.name}") + expect(response.parsed_body["members_count"]).to eq(1) + expect(response.parsed_body["private"]).to eq(true) + end + + it "returns the everyone group when a category is public" do + Fabricate(:user) + category_2 = Fabricate(:category) + everyone_group = Group.find(Group::AUTO_GROUPS[:everyone]) + + get "/chat/api/category-chatables/#{category_2.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{everyone_group.name}") + expect(response.parsed_body["members_count"]).to be_nil + expect(response.parsed_body["private"]).to eq(false) + end + + it "includes the number of users with access" do + number_of_users = 3 + number_of_users.times { group.add(Fabricate(:user)) } + + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.parsed_body["allowed_groups"]).to contain_exactly("@#{group.name}") + expect(response.parsed_body["members_count"]).to eq(number_of_users) + expect(response.parsed_body["private"]).to eq(true) + end + + it "returns a 404 when passed an invalid category" do + get "/chat/api/category-chatables/-99/permissions.json" + + expect(response.status).to eq(404) + end + end + + context "as anon" do + it "returns a 404" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.status).to eq(404) + end + end + + context "when signed in as a regular user" do + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "returns a 404" do + get "/chat/api/category-chatables/#{private_category.id}/permissions.json" + + expect(response.status).to eq(404) + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb b/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb new file mode 100644 index 00000000000..5c765321242 --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channel_memberships_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::ChatChannelMembershipsController do + fab!(:user_1) { Fabricate(:user, username: "bob") } + fab!(:user_2) { Fabricate(:user, username: "clark") } + fab!(:channel_1) { Fabricate(:category_channel) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#index" do + include_examples "channel access example", :get, "/memberships.json" + + context "when memberships exist" do + before do + UserChatChannelMembership.create(user: user_1, chat_channel: channel_1, following: true) + UserChatChannelMembership.create( + user: Fabricate(:user), + chat_channel: channel_1, + following: false, + ) + UserChatChannelMembership.create(user: user_2, chat_channel: channel_1, following: true) + sign_in(user_1) + end + + it "lists followed memberships" do + get "/chat/api/chat_channels/#{channel_1.id}/memberships.json" + + expect(response.parsed_body.length).to eq(2) + expect(response.parsed_body[0]["user"]["id"]).to eq(user_1.id) + expect(response.parsed_body[1]["user"]["id"]).to eq(user_2.id) + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb new file mode 100644 index 00000000000..0ab7ddceb8a --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channel_notifications_settings_controller_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +RSpec.describe Chat::Api::ChatChannelNotificationsSettingsController do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#update" do + include_examples "channel access example", :put, "/notifications_settings.json" + + context "when category channel has invalid params" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "doesn’t use invalid params" do + UserChatChannelMembership.any_instance.expects(:update!).with({ "muted" => "true" }).once + + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + foo: 1, + } + + expect(response.status).to eq(200) + end + end + + context "when category channel has valid params" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate( + :user_chat_channel_membership, + muted: false, + user: user, + chat_channel: chat_channel, + ) + end + + before { sign_in(user) } + + it "updates the notifications settings" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + desktop_notification_level: "always", + mobile_notification_level: "never", + } + + expect(response.status).to eq(200) + expect(response.parsed_body).to match_response_schema("user_chat_channel_membership") + + membership.reload + + expect(membership.muted).to eq(true) + expect(membership.desktop_notification_level).to eq("always") + expect(membership.mobile_notification_level).to eq("never") + end + end + + context "when membership doesn’t exist" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "raises a 404" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json" + + expect(response.status).to eq(404) + end + end + + context "when direct message channel has invalid params" do + fab!(:user) { Fabricate(:user) } + fab!(:chatable) { Fabricate(:direct_message_channel, users: [user, Fabricate(:user)]) } + fab!(:chat_channel) { Fabricate(:dm_channel, chatable: chatable) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "doesn’t use invalid params" do + UserChatChannelMembership.any_instance.expects(:update!).with({ "muted" => "true" }).once + + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + foo: 1, + } + + expect(response.status).to eq(200) + end + end + + context "when direct message channel has valid params" do + fab!(:user) { Fabricate(:user) } + fab!(:chatable) { Fabricate(:direct_message_channel, users: [user, Fabricate(:user)]) } + fab!(:chat_channel) { Fabricate(:dm_channel, chatable: chatable) } + fab!(:membership) do + Fabricate( + :user_chat_channel_membership, + muted: false, + user: user, + chat_channel: chat_channel, + ) + end + + before { sign_in(user) } + + it "updates the notifications settings" do + put "/chat/api/chat_channels/#{chat_channel.id}/notifications_settings.json", + params: { + muted: true, + desktop_notification_level: "always", + mobile_notification_level: "never", + } + + expect(response.status).to eq(200) + expect(response.parsed_body).to match_response_schema("user_chat_channel_membership") + + membership.reload + + expect(membership.muted).to eq(true) + expect(membership.desktop_notification_level).to eq("always") + expect(membership.mobile_notification_level).to eq("never") + end + end + end +end diff --git a/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb new file mode 100644 index 00000000000..32e8aa61a64 --- /dev/null +++ b/plugins/chat/spec/requests/api/chat_channels_controller_spec.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Chat::Api::ChatChannelsController do + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#index" do + context "as anonymous user" do + it "returns a 403" do + get "/chat/api/chat_channels.json" + expect(response.status).to eq(403) + end + end + + describe "params" do + fab!(:opened_channel) { Fabricate(:category_channel, name: "foo") } + fab!(:closed_channel) { Fabricate(:category_channel, name: "bar", status: :closed) } + + before { sign_in(Fabricate(:user)) } + + it "returns all channels by default" do + get "/chat/api/chat_channels.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.length).to eq(2) + end + + it "returns serialized channels " do + get "/chat/api/chat_channels.json" + + expect(response.status).to eq(200) + response.parsed_body.each do |channel| + expect(channel).to match_response_schema("category_chat_channel") + end + end + + describe "filter" do + it "returns channels filtered by name" do + get "/chat/api/chat_channels.json?filter=foo" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + expect(results[0]["title"]).to eq("foo") + end + end + + describe "status" do + it "returns channels with the status" do + get "/chat/api/chat_channels.json?status=closed" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + expect(results[0]["status"]).to eq("closed") + end + end + + describe "limit" do + it "returns a number of channel equal to the limit" do + get "/chat/api/chat_channels.json?limit=1" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(1) + end + end + describe "offset" do + it "returns channels from the offset" do + get "/chat/api/chat_channels.json?offset=2" + + expect(response.status).to eq(200) + results = response.parsed_body + expect(results.length).to eq(0) + end + end + end + end + + describe "#create" do + fab!(:admin) { Fabricate(:admin) } + fab!(:category) { Fabricate(:category) } + + let(:params) do + { + type: category.class.name, + id: category.id, + name: "channel name", + description: "My new channel", + } + end + + before { sign_in(admin) } + + it "creates a channel associated to a category" do + put "/chat/chat_channels.json", params: params + + new_channel = ChatChannel.last + + expect(new_channel.name).to eq(params[:name]) + expect(new_channel.description).to eq(params[:description]) + expect(new_channel.chatable_type).to eq(category.class.name) + expect(new_channel.chatable_id).to eq(category.id) + end + + it "creates a channel sets auto_join_users to false by default" do + put "/chat/chat_channels.json", params: params + + new_channel = ChatChannel.last + + expect(new_channel.auto_join_users).to eq(false) + end + + it "creates a channel with auto_join_users set to true" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: true) + + new_channel = ChatChannel.last + + expect(new_channel.auto_join_users).to eq(true) + end + + describe "triggers the auto-join process" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:user) { Fabricate(:user, last_seen_at: 15.minute.ago) } + + before do + Jobs.run_immediately! + Fabricate(:category_group, category: category, group: chatters_group) + chatters_group.add(user) + end + + it "joins the user when auto_join_users is true" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: true) + + created_channel_id = response.parsed_body.dig("chat_channel", "id") + membership_exists = + UserChatChannelMembership.find_by( + user: user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_present + end + + it "doesn't join the user when auto_join_users is false" do + put "/chat/chat_channels.json", params: params.merge(auto_join_users: false) + + created_channel_id = response.parsed_body.dig("chat_channel", "id") + membership_exists = + UserChatChannelMembership.find_by( + user: user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_nil + end + end + end + + describe "#update" do + include_examples "channel access example", :put + + context "when user can’t edit channel" do + fab!(:chat_channel) { Fabricate(:category_channel) } + + before { sign_in(Fabricate(:user)) } + + it "returns a 403" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.status).to eq(403) + end + end + + context "when user provided invalid params" do + fab!(:chat_channel) { Fabricate(:category_channel, user_count: 10) } + + before { sign_in(Fabricate(:admin)) } + + it "doesn’t change invalid properties" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { user_count: 40 } + + expect(chat_channel.reload.user_count).to eq(10) + end + end + + context "when user provided an empty name" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) do + Fabricate(:category_channel, name: "something", description: "something else") + end + + before { sign_in(user) } + + it "nullifies the field and doesn’t store an empty string" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { name: " " } + + expect(chat_channel.reload.name).to be_nil + end + + it "doesn’t nullify the description" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { name: " " } + + expect(chat_channel.reload.description).to eq("something else") + end + end + + context "when user provides an empty description" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) do + Fabricate(:category_channel, name: "something else", description: "something") + end + + before { sign_in(user) } + + it "nullifies the field and doesn’t store an empty string" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { description: " " } + + expect(chat_channel.reload.description).to be_nil + end + + it "doesn’t nullify the name" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { description: " " } + + expect(chat_channel.reload.name).to eq("something else") + end + end + + context "when channel is a direct message channel" do + fab!(:user) { Fabricate(:admin) } + fab!(:chatable) { Fabricate(:direct_message_channel) } + fab!(:chat_channel) { Fabricate(:dm_channel, chatable: chatable) } + + before { sign_in(user) } + + it "raises a 403" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.status).to eq(403) + end + end + + context "when user provides valid params" do + fab!(:user) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:category_channel) } + + before { sign_in(user) } + + it "sets properties" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", + params: { + name: "joffrey", + description: "cat owner", + } + + expect(chat_channel.reload.name).to eq("joffrey") + expect(chat_channel.reload.description).to eq("cat owner") + end + + it "publishes an update" do + messages = + MessageBus.track_publish("/chat/channel-edits") do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + end + + expect(messages[0].data[:chat_channel_id]).to eq(chat_channel.id) + end + + it "returns a valid chat channel" do + put "/chat/api/chat_channels/#{chat_channel.id}.json" + + expect(response.parsed_body).to match_response_schema("category_chat_channel") + end + + describe "Updating a channel to add users automatically" do + it "sets the channel to auto-update users automatically" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + expect(response.parsed_body["auto_join_users"]).to eq(true) + end + + it "tells staff members to slow down when toggling auto-update multiple times" do + RateLimiter.enable + + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: false } + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + expect(response.status).to eq(429) + end + + describe "triggers the auto-join process" do + fab!(:chatters_group) { Fabricate(:group) } + fab!(:another_user) { Fabricate(:user, last_seen_at: 15.minute.ago) } + + before do + Jobs.run_immediately! + Fabricate(:category_group, category: chat_channel.chatable, group: chatters_group) + chatters_group.add(another_user) + end + + it "joins the user when auto_join_users is true" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", params: { auto_join_users: true } + + created_channel_id = response.parsed_body["id"] + membership_exists = + UserChatChannelMembership.find_by( + user: another_user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_present + end + + it "doesn't join the user when auto_join_users is false" do + put "/chat/api/chat_channels/#{chat_channel.id}.json", + params: { + auto_join_users: false, + } + + created_channel_id = response.parsed_body["id"] + membership_exists = + UserChatChannelMembership.find_by( + user: another_user, + chat_channel_id: created_channel_id, + following: true, + ) + + expect(membership_exists).to be_nil + end + end + end + end + end +end diff --git a/plugins/chat/spec/requests/chat_channel_controller_spec.rb b/plugins/chat/spec/requests/chat_channel_controller_spec.rb new file mode 100644 index 00000000000..b2f05aa45c7 --- /dev/null +++ b/plugins/chat/spec/requests/chat_channel_controller_spec.rb @@ -0,0 +1,840 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ChatChannelsController do + fab!(:user) { Fabricate(:user, username: "johndoe", name: "John Doe") } + fab!(:other_user) { Fabricate(:user, username: "janemay", name: "Jane May") } + fab!(:admin) { Fabricate(:admin, username: "andyjones", name: "Andy Jones") } + fab!(:category) { Fabricate(:category) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_chat_channel) do + Fabricate(:dm_channel, chatable: Fabricate(:direct_message_channel, users: [user, admin])) + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + SiteSetting.chat_duplicate_message_sensitivity = 0 + end + + describe "#index" do + fab!(:private_group) { Fabricate(:group) } + fab!(:user_with_private_access) { Fabricate(:user, group_ids: [private_group.id]) } + + fab!(:private_category) { Fabricate(:private_category, group: private_group) } + fab!(:private_category_cc) { Fabricate(:category_channel, chatable: private_category) } + + describe "with memberships for all channels" do + before do + ChatChannel.all.each do |cc| + model = + ( + if cc.direct_message_channel? + :user_chat_channel_membership_for_dm + else + :user_chat_channel_membership + end + ) + + Fabricate(model, chat_channel: cc, user: user) + Fabricate(model, chat_channel: cc, user: user_with_private_access) + Fabricate(model, chat_channel: cc, user: admin) + end + end + + it "errors for user that is not allowed to chat" do + sign_in(user) + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + + get "/chat/chat_channels.json" + + expect(response.status).to eq(403) + end + + it "returns public channels to only-public user" do + sign_in(user) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id]) + end + + it "returns channels visible to user with private access" do + sign_in(user_with_private_access) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id, private_category_cc.id]) + end + + it "returns all channels for admin" do + sign_in(admin) + get "/chat/chat_channels.json" + + expect(response.status).to eq(200) + expect( + response.parsed_body["public_channels"].map { |channel| channel["id"] }, + ).to match_array([chat_channel.id, private_category_cc.id]) + end + + it "doesn't error when a chat channel's chatable is destroyed" do + sign_in(user_with_private_access) + private_category.destroy! + + get "/chat/chat_channels.json" + expect(response.status).to eq(200) + end + + it "serializes unread_mentions properly" do + sign_in(admin) + Jobs.run_immediately! + Chat::ChatMessageCreator.create( + chat_channel: chat_channel, + user: user, + content: "Hi @#{admin.username}", + ) + get "/chat/chat_channels.json" + cc = response.parsed_body["public_channels"].detect { |c| c["id"] == chat_channel.id } + expect(cc["current_user_membership"]["unread_mentions"]).to eq(1) + end + + describe "direct messages" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + + before do + Group.refresh_automatic_groups! + @dm1 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user2], + ) + @dm2 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user3], + ) + @dm3 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user1, user2, user3], + ) + @dm4 = + Chat::DirectMessageChannelCreator.create!( + acting_user: user1, + target_users: [user2, user3], + ) + end + + it "returns correct DMs for creator" do + sign_in(user1) + + get "/chat/chat_channels.json" + expect( + response.parsed_body["direct_message_channels"].map { |c| c["id"] }, + ).to match_array([@dm1.id, @dm2.id, @dm3.id]) + end + + it "returns correct DMs when not following" do + sign_in(user2) + + get "/chat/chat_channels.json" + expect( + response.parsed_body["direct_message_channels"].map { |c| c["id"] }, + ).to match_array([]) + end + + it "returns correct DMs when following" do + user3 + .user_chat_channel_memberships + .where(chat_channel_id: @dm3.id) + .update!(following: true) + + sign_in(user3) + + get "/chat/chat_channels.json" + dm3_response = response.parsed_body + expect(dm3_response["direct_message_channels"].map { |c| c["id"] }).to match_array( + [@dm3.id], + ) + end + + it "correctly set unread_count for DMs for creator" do + sign_in(user1) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm2_response = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm2_response["current_user_membership"]["unread_count"]).to eq(0) + end + + it "correctly set membership for DMs when user is not following" do + sign_in(user2) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm2_channel = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm2_channel).to be_nil + end + + it "correctly set unread_count for DMs when user is following" do + user3 + .user_chat_channel_memberships + .where(chat_channel_id: @dm2.id) + .update!(following: true) + + sign_in(user3) + Chat::ChatMessageCreator.create( + chat_channel: @dm2, + user: user1, + content: "What's going on?!", + ) + get "/chat/chat_channels.json" + dm3_response = + response.parsed_body["direct_message_channels"].detect { |c| c["id"] == @dm2.id } + expect(dm3_response["current_user_membership"]["unread_count"]).to eq(1) + end + end + end + end + + describe "#follow" do + it "creates a user_chat_channel_membership record if one doesn't exist" do + sign_in(user) + expect { post "/chat/chat_channels/#{chat_channel.id}/follow.json" }.to change { + UserChatChannelMembership.where(user_id: user.id, following: true).count + }.by(1) + expect(response.status).to eq(200) + end + + it "updates 'following' to true for existing record" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: chat_channel.id, + user_id: user.id, + following: false, + ) + + expect { post "/chat/chat_channels/#{chat_channel.id}/follow.json" }.to change { + membership_record.reload.following + }.to(true).from(false) + expect(response.status).to eq(200) + expect(response.parsed_body["current_user_membership"]["following"]).to eq(true) + expect(response.parsed_body["current_user_membership"]["chat_channel_id"]).to eq( + chat_channel.id, + ) + end + end + + describe "#unfollow" do + it "updates 'following' to false for existing record" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: chat_channel.id, + user_id: user.id, + following: true, + ) + + expect { post "/chat/chat_channels/#{chat_channel.id}/unfollow.json" }.to change { + membership_record.reload.following + }.to(false).from(true) + expect(response.status).to eq(200) + expect(response.parsed_body["current_user_membership"]["following"]).to eq(false) + expect(response.parsed_body["current_user_membership"]["chat_channel_id"]).to eq( + chat_channel.id, + ) + end + + it "allows to unfollow a direct_message_channel" do + sign_in(user) + membership_record = + UserChatChannelMembership.create!( + chat_channel_id: dm_chat_channel.id, + user_id: user.id, + following: true, + desktop_notification_level: 2, + mobile_notification_level: 2, + ) + + post "/chat/chat_channels/#{dm_chat_channel.id}/unfollow.json" + expect(response.status).to eq(200) + expect(membership_record.reload.following).to eq(false) + end + end + + describe "#create" do + fab!(:category2) { Fabricate(:category) } + + it "errors for non-staff" do + sign_in(user) + put "/chat/chat_channels.json", params: { id: category2.id, name: "hi" } + expect(response.status).to eq(403) + end + + it "errors when chatable doesn't exist" do + sign_in(admin) + put "/chat/chat_channels.json", params: { id: Category.last.id + 1, name: "hi" } + expect(response.status).to eq(404) + end + + it "errors when the name is over SiteSetting.max_topic_title_length" do + sign_in(admin) + SiteSetting.max_topic_title_length = 10 + put "/chat/chat_channels.json", + params: { + id: category2.id, + name: "Hi, this is over 10 characters", + } + expect(response.status).to eq(400) + end + + it "errors when channel for category and same name already exists" do + sign_in(admin) + name = "beep boop hi" + ChatChannel.create!(chatable: category2, name: name) + + put "/chat/chat_channels.json", params: { id: category2.id, name: name } + expect(response.status).to eq(400) + end + + it "creates a channel for category and if name is unique" do + sign_in(admin) + ChatChannel.create!(chatable: category2, name: "this is a name") + + expect { + put "/chat/chat_channels.json", params: { id: category2.id, name: "Different name!" } + }.to change { ChatChannel.where(chatable: category2).count }.by(1) + expect(response.status).to eq(200) + end + + it "creates a user_chat_channel_membership when the channel is created" do + sign_in(admin) + expect { + put "/chat/chat_channels.json", params: { id: category2.id, name: "hi hi" } + }.to change { UserChatChannelMembership.where(user: admin).count }.by(1) + expect(response.status).to eq(200) + end + end + + describe "#edit" do + it "errors for non-staff" do + sign_in(user) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: "hello" } + expect(response.status).to eq(403) + end + + it "returns a 404 when chat_channel doesn't exist" do + sign_in(admin) + chat_channel.destroy! + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: "hello" } + expect(response.status).to eq(404) + end + + it "updates name correctly and leaves description alone" do + sign_in(admin) + new_name = "newwwwwwwww name" + description = "this is something" + chat_channel.update(description: description) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { name: new_name } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(new_name) + expect(chat_channel.description).to eq(description) + end + + it "updates name correctly and leaves description alone" do + sign_in(admin) + name = "beep boop" + new_description = "this is something" + chat_channel.update(name: name) + post "/chat/chat_channels/#{chat_channel.id}.json", params: { description: new_description } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(name) + expect(chat_channel.description).to eq(new_description) + end + + it "updates name and description together" do + sign_in(admin) + new_name = "beep boop" + new_description = "this is something" + post "/chat/chat_channels/#{chat_channel.id}.json", + params: { + name: new_name, + description: new_description, + } + expect(response.status).to eq(200) + expect(chat_channel.reload.name).to eq(new_name) + expect(chat_channel.description).to eq(new_description) + end + end + + describe "#search" do + describe "without chat permissions" do + it "errors errors for anon" do + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(403) + end + + it "errors when user cannot chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + sign_in(user) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(403) + end + end + + describe "with chat permissions" do + before do + sign_in(user) + chat_channel.update(name: "something") + end + + it "returns the correct channels with filter 'so'" do + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the correct channels with filter 'something'" do + get "/chat/chat_channels/search.json", params: { filter: "something" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the correct channels with filter 'andyjones'" do + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "returns the current user inside the users array if their username matches the filter too" do + user.update(username: "andysmith") + get "/chat/chat_channels/search.json", params: { filter: "andy" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + expect(response.parsed_body["users"].map { |u| u["id"] }).to match_array([user.id]) + end + + it "returns no channels with a whacky filter" do + get "/chat/chat_channels/search.json", params: { filter: "hello good sir" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + expect(response.parsed_body["users"].count).to eq(0) + end + + it "only returns open channels" do + chat_channel.update(status: ChatChannel.statuses[:closed]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + chat_channel.update(status: ChatChannel.statuses[:read_only]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + chat_channel.update(status: ChatChannel.statuses[:archived]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"].count).to eq(0) + + # Now set status to open and the channel is there! + chat_channel.update(status: ChatChannel.statuses[:open]) + get "/chat/chat_channels/search.json", params: { filter: "so" } + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + + it "only finds users by username_lower if not enable_names" do + SiteSetting.enable_names = false + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "only finds users by username if prioritize_username_in_ux" do + SiteSetting.prioritize_username_in_ux = true + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "can find users by name or username if not prioritize_username_in_ux and enable_names" do + SiteSetting.prioritize_username_in_ux = false + SiteSetting.enable_names = true + get "/chat/chat_channels/search.json", params: { filter: "Andy J" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"].count).to eq(0) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "does not return DM channels for users who do not have chat enabled" do + admin.user_option.update!(chat_enabled: false) + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + end + + it "does not return DM channels for users who are not in the chat allowed group" do + group = Fabricate(:group, name: "chatpeeps") + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + dm_chat_channel_2 = + Fabricate( + :dm_channel, + chatable: Fabricate(:direct_message_channel, users: [user, other_user]), + ) + + get "/chat/chat_channels/search.json", params: { filter: "janemay" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"].count).to eq(0) + + GroupUser.create(user: other_user, group: group) + get "/chat/chat_channels/search.json", params: { filter: "janemay" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel_2.id) + end + + it "returns DM channels for staff users even if they are not in chat_allowed_groups" do + group = Fabricate(:group, name: "chatpeeps") + SiteSetting.chat_allowed_groups = group.id + GroupUser.create(user: user, group: group) + + get "/chat/chat_channels/search.json", params: { filter: "andyjones" } + expect(response.status).to eq(200) + expect(response.parsed_body["direct_message_channels"][0]["id"]).to eq(dm_chat_channel.id) + end + + it "returns followed channels" do + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: chat_channel, + following: true, + ) + + get "/chat/chat_channels/search.json", params: { filter: chat_channel.name } + + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + + it "returns not followed channels" do + Fabricate( + :user_chat_channel_membership, + user: user, + chat_channel: chat_channel, + following: false, + ) + + get "/chat/chat_channels/search.json", params: { filter: chat_channel.name } + + expect(response.status).to eq(200) + expect(response.parsed_body["public_channels"][0]["id"]).to eq(chat_channel.id) + end + end + end + + describe "#show" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "My Great Channel & Stuff") + end + + it "can find channel by id" do + sign_in(user) + get "/chat/chat_channels/#{channel.id}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "can find channel by name" do + sign_in(user) + get "/chat/chat_channels/#{UrlHelper.encode_component("My Great Channel & Stuff")}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "can find channel by chatable title/name" do + sign_in(user) + + channel.update!(chatable: Fabricate(:category, name: "Support Chat")) + get "/chat/chat_channels/#{UrlHelper.encode_component("Support Chat")}.json" + expect(response.status).to eq(200) + expect(response.parsed_body["id"]).to eq(channel.id) + end + + it "gives a not found error if the channel cannot be found by name or id" do + channel.destroy + sign_in(user) + get "/chat/chat_channels/#{channel.id}.json" + expect(response.status).to eq(404) + get "/chat/chat_channels/#{UrlHelper.encode_component(channel.name)}.json" + expect(response.status).to eq(404) + end + end + + describe "#archive" do + fab!(:channel) { Fabricate(:category_channel, chatable: category, name: "The English Channel") } + let(:new_topic_params) do + { type: "newTopic", title: "This is a test archive topic", category_id: category.id } + end + let(:existing_topic_params) { { type: "existingTopic", topic_id: Fabricate(:topic).id } } + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(403) + end + + it "returns error if type or chat_channel_id is not provided" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: {} + expect(response.status).to eq(400) + end + + it "returns error if title is not provided for new topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: { type: "newTopic" } + expect(response.status).to eq(400) + end + + it "returns error if topic_id is not provided for existing topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: { type: "existingTopic" } + expect(response.status).to eq(400) + end + + it "returns error if the channel cannot be archived" do + channel.update!(status: :archived) + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(403) + end + + it "starts the archive process using a new topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + expect(channel.reload.status).to eq("read_only") + end + + it "starts the archive process using an existing topic" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: existing_topic_params + channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + expect( + job_enqueued?( + job: :chat_channel_archive, + args: { + chat_channel_archive_id: channel_archive.id, + }, + ), + ).to eq(true) + expect(channel.reload.status).to eq("read_only") + end + + it "does nothing if the chat channel archive already exists" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + expect(response.status).to eq(200) + expect { + put "/chat/chat_channels/#{channel.id}/archive.json", params: new_topic_params + }.not_to change { ChatChannelArchive.count } + end + end + + describe "#retry_archive" do + fab!(:channel) do + Fabricate( + :category_channel, + chatable: category, + name: "The English Channel", + status: :read_only, + ) + end + fab!(:archive) do + ChatChannelArchive.create!( + chat_channel: channel, + destination_topic_title: "test archive topic title", + archived_by: admin, + total_messages: 10, + ) + end + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "returns a 404 if the archive has not been started" do + archive.destroy + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(404) + end + + it "returns a 403 error if the archive is not currently failed" do + sign_in(admin) + archive.update!(archive_error: nil) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "returns a 403 error if the channel is not read_only" do + sign_in(admin) + archive.update!(archive_error: "bad stuff", archived_messages: 1) + channel.update!(status: "open") + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(403) + end + + it "re-enqueues the archive job" do + sign_in(admin) + archive.update!(archive_error: "bad stuff", archived_messages: 1) + put "/chat/chat_channels/#{channel.id}/retry_archive.json" + expect(response.status).to eq(200) + expect( + job_enqueued?(job: :chat_channel_archive, args: { chat_channel_archive_id: archive.id }), + ).to eq(true) + end + end + + describe "#change_status" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "Channel Orange", status: :open) + end + + it "returns error if user is not staff" do + sign_in(user) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(403) + end + + it "returns a 404 if the channel does not exist" do + channel.destroy! + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(404) + end + + it "returns a 400 if the channel status is not closed or open" do + channel.update!(status: "read_only") + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(403) + end + + it "changes the channel to closed if it is open" do + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "closed" } + expect(response.status).to eq(200) + expect(channel.reload.status).to eq("closed") + end + + it "changes the channel to open if it is closed" do + channel.update!(status: "closed") + sign_in(admin) + put "/chat/chat_channels/#{channel.id}/change_status.json", params: { status: "open" } + expect(response.status).to eq(200) + expect(channel.reload.status).to eq("open") + end + end + + describe "#delete" do + fab!(:channel) do + Fabricate(:category_channel, chatable: category, name: "Ambrose Channel", status: :open) + end + + it "returns error if user is not staff" do + sign_in(user) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(403) + end + + it "returns a 404 if the channel does not exist" do + channel.destroy! + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(404) + end + + it "returns a 400 if the channel_name_confirmation does not match the channel name" do + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "some Other channel", + } + expect(response.status).to eq(400) + end + + it "deletes the channel right away and enqueues the background job to delete all its chat messages and related content" do + sign_in(admin) + delete "/chat/chat_channels/#{channel.id}.json", + params: { + channel_name_confirmation: "ambrose channel", + } + expect(response.status).to eq(200) + expect(channel.reload.trashed?).to eq(true) + expect(job_enqueued?(job: :chat_channel_delete, args: { chat_channel_id: channel.id })).to eq( + true, + ) + expect( + UserHistory.exists?( + acting_user_id: admin.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_delete", + ), + ).to eq(true) + end + end +end diff --git a/plugins/chat/spec/requests/chat_controller_spec.rb b/plugins/chat/spec/requests/chat_controller_spec.rb new file mode 100644 index 00000000000..5550625f546 --- /dev/null +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -0,0 +1,1482 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ChatController do + fab!(:user) { Fabricate(:user) } + fab!(:other_user) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + fab!(:category) { Fabricate(:category) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + fab!(:dm_chat_channel) do + Fabricate( + :dm_channel, + chatable: Fabricate(:direct_message_channel, users: [user, other_user, admin]), + ) + end + fab!(:tag) { Fabricate(:tag) } + + MESSAGE_COUNT = 70 + MESSAGE_COUNT.times do |n| + fab!("message_#{n}") do + Fabricate( + :chat_message, + chat_channel: chat_channel, + user: other_user, + message: "message #{n}", + ) + end + end + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + def flag_message(message, flagger, flag_type: ReviewableScore.types[:off_topic]) + Chat::ChatReviewQueue.new.flag_message(message, Guardian.new(flagger), flag_type)[:reviewable] + end + + describe "#messages" do + let(:page_size) { 30 } + + before do + sign_in(user) + Group.refresh_automatic_groups! + end + + it "errors for user when they are not allowed to chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.status).to eq(403) + end + + it "errors when page size is over 50" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: 51 } + expect(response.status).to eq(400) + end + + it "errors when page size is nil" do + get "/chat/#{chat_channel.id}/messages.json" + expect(response.status).to eq(400) + end + + it "returns the latest messages in created_at, id order" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to be < messages.last["created_at"].to_time + end + + it "returns `can_flag=true` for public channels" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_flag"]).to be true + end + + it "returns `can_flag=true` for DM channels" do + get "/chat/#{dm_chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_flag"]).to be true + end + + it "returns `can_moderate=true` based on whether the user can moderate the chatable" do + 1.upto(4) do |n| + user.update!(trust_level: n) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be false + end + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be false + + user.update!(admin: true) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be true + user.update!(admin: false) + + SiteSetting.enable_category_group_moderation = true + group = Fabricate(:group) + group.add(user) + category.update!(reviewable_by_group: group) + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["meta"]["can_moderate"]).to be true + end + + it "serializes `user_flag_status` for user who has a pending flag" do + chat_message = chat_channel.chat_messages.last + reviewable = flag_message(chat_message, user) + score = reviewable.reviewable_scores.last + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["chat_messages"].last["user_flag_status"]).to eq( + score.status_for_database, + ) + end + + it "doesn't serialize `reviewable_ids` for non-staff" do + reviewable = flag_message(chat_channel.chat_messages.last, admin) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["chat_messages"].last["reviewable_id"]).to be_nil + end + + it "serializes `reviewable_ids` correctly for staff" do + sign_in(admin) + reviewable = flag_message(chat_channel.chat_messages.last, admin) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + expect(response.parsed_body["chat_messages"].last["reviewable_id"]).to eq(reviewable.id) + end + + it "correctly marks reactions as 'reacted' for the current_user" do + heart_emoji = ":heart:" + smile_emoji = ":smile" + + last_message = chat_channel.chat_messages.last + last_message.reactions.create(user: user, emoji: heart_emoji) + last_message.reactions.create(user: admin, emoji: smile_emoji) + + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + reactions = response.parsed_body["chat_messages"].last["reactions"] + expect(reactions[heart_emoji]["reacted"]).to be true + expect(reactions[smile_emoji]["reacted"]).to be false + end + + describe "scrolling to the past" do + it "returns the correct messages in created_at, id order" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_40.id, + direction: described_class::PAST, + page_size: page_size, + } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to eq_time(message_10.created_at) + expect(messages.last["created_at"].to_time).to eq_time(message_39.created_at) + end + + it "returns 'can_load...' properly when there are more past messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_40.id, + direction: described_class::PAST, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be true + expect(response.parsed_body["meta"]["can_load_more_future"]).to be_nil + end + + it "returns 'can_load...' properly when there are no past messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_3.id, + direction: described_class::PAST, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be false + expect(response.parsed_body["meta"]["can_load_more_future"]).to be_nil + end + end + + describe "scrolling to the future" do + it "returns the correct messages in created_at, id order when there are many after" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_10.id, + direction: described_class::FUTURE, + page_size: page_size, + } + messages = response.parsed_body["chat_messages"] + expect(messages.count).to eq(page_size) + expect(messages.first["created_at"].to_time).to eq_time(message_11.created_at) + expect(messages.last["created_at"].to_time).to eq_time(message_40.created_at) + end + + it "return 'can_load..' properly when there are future messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_10.id, + direction: described_class::FUTURE, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be_nil + expect(response.parsed_body["meta"]["can_load_more_future"]).to be true + end + + it "returns 'can_load..' properly when there are no future messages" do + get "/chat/#{chat_channel.id}/messages.json", + params: { + message_id: message_60.id, + direction: described_class::FUTURE, + page_size: page_size, + } + expect(response.parsed_body["meta"]["can_load_more_past"]).to be_nil + expect(response.parsed_body["meta"]["can_load_more_future"]).to be false + end + end + + describe "without direction (latest messages)" do + it "signals there are no future messages" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["meta"]["can_load_more_future"]).to eq(false) + end + + it "signals there are more messages in the past" do + get "/chat/#{chat_channel.id}/messages.json", params: { page_size: page_size } + + expect(response.parsed_body["meta"]["can_load_more_past"]).to eq(true) + end + + it "signals there are no more messages" do + new_channel = Fabricate(:category_channel) + Fabricate(:chat_message, chat_channel: new_channel, user: other_user, message: "message") + chat_messages_qty = 1 + + get "/chat/#{new_channel.id}/messages.json", params: { page_size: chat_messages_qty + 1 } + + expect(response.parsed_body["meta"]["can_load_more_past"]).to eq(false) + end + end + end + + describe "#enable_chat" do + context "with category as chatable" do + let!(:category) { Fabricate(:category) } + let(:channel) { Fabricate(:category_channel, chatable: category) } + + it "ensures created channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(admin) + post "/chat/enable.json", params: { chatable_type: "category", chatable_id: category.id } + end + + # TODO: rewrite specs to ensure no exception is raised + it "ensures existing channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?) + + sign_in(admin) + post "/chat/enable.json", params: { chatable_type: "category", chatable_id: category.id } + end + end + end + + describe "#disable_chat" do + context "with category as chatable" do + it "ensures category can be seen" do + category = Fabricate(:category) + channel = Fabricate(:category_channel, chatable: category) + message = Fabricate(:chat_message, chat_channel: channel) + + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(admin) + post "/chat/disable.json", params: { chatable_type: "category", chatable_id: category.id } + end + end + end + + describe "#create_message" do + let(:message) { "This is a message" } + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + context "when current user is silenced" do + before do + UserChatChannelMembership.create(user: user, chat_channel: chat_channel, following: true) + sign_in(user) + UserSilencer.new(user).silence + end + + it "raises invalid acces" do + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + end + + it "errors for regular user when chat is staff-only" do + sign_in(user) + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] + + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + + it "errors when the user isn't following the channel" do + sign_in(user) + + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + + it "errors when the user is not staff and the channel is not open" do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + sign_in(user) + + chat_channel.update(status: :closed) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "errors when the user is staff and the channel is not open or closed" do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: admin) + sign_in(admin) + + chat_channel.update(status: :closed) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(200) + + chat_channel.update(status: :read_only) + post "/chat/#{chat_channel.id}.json", params: { message: message } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "sends a message for regular user when staff-only is disabled and they are following channel" do + sign_in(user) + UserChatChannelMembership.create(user: user, chat_channel: chat_channel, following: true) + + expect { post "/chat/#{chat_channel.id}.json", params: { message: message } }.to change { + ChatMessage.count + }.by(1) + expect(response.status).to eq(200) + expect(ChatMessage.last.message).to eq(message) + end + end + + describe "for direct message" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:chatable) { Fabricate(:direct_message_channel, users: [user1, user2]) } + fab!(:direct_message_channel) { Fabricate(:dm_channel, chatable: chatable) } + + def create_memberships + UserChatChannelMembership.create!( + user: user1, + chat_channel: direct_message_channel, + following: true, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + UserChatChannelMembership.create!( + user: user2, + chat_channel: direct_message_channel, + following: false, + desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + ) + Group.refresh_automatic_groups! + end + + it "forces users to follow the channel" do + create_memberships + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + + ChatPublisher.expects(:publish_new_channel).once + + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be true + end + + it "errors when the user is not part of the direct message channel" do + create_memberships + + DirectMessageUser.find_by(user: user1, direct_message_channel: chatable).destroy! + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + + UserChatChannelMembership.find_by(user_id: user2.id).update!(following: true) + sign_in(user2) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(200) + end + + context "when current user is silenced" do + before do + create_memberships + sign_in(user1) + UserSilencer.new(user1).silence + end + + it "raises invalid acces" do + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + expect(response.status).to eq(403) + end + end + + context "if any of the direct message users is ignoring the acting user" do + before do + IgnoredUser.create!(user: user2, ignored_user: user1, expiring_at: 1.day.from_now) + end + + it "does not force them to follow the channel or send a publish_new_channel message" do + create_memberships + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + + ChatPublisher.expects(:publish_new_channel).never + + sign_in(user1) + post "/chat/#{direct_message_channel.id}.json", params: { message: message } + + expect(UserChatChannelMembership.find_by(user_id: user2.id).following).to be false + end + end + end + end + + describe "#rebake" do + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + + context "as staff" do + it "rebakes the post" do + sign_in(Fabricate(:admin)) + + expect_enqueued_with( + job: :process_chat_message, + args: { + chat_message_id: chat_message.id, + }, + ) do + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + + expect(response.status).to eq(200) + end + end + + it "does not interfere with core's guardian can_rebake? for posts" do + sign_in(Fabricate(:admin)) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(200) + post = Fabricate(:post) + put "/posts/#{post.id}/rebake.json" + expect(response.status).to eq(200) + end + + it "does not rebake the post when channel is read_only" do + chat_message.chat_channel.update!(status: :read_only) + sign_in(Fabricate(:admin)) + + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + + context "when cooked has changed" do + it "marks the message as dirty" do + sign_in(Fabricate(:admin)) + chat_message.update!(message: "new content") + + expect_enqueued_with( + job: :process_chat_message, + args: { + chat_message_id: chat_message.id, + is_dirty: true, + }, + ) do + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + + expect(response.status).to eq(200) + end + end + end + end + + context "when not staff" do + it "forbids non staff to rebake" do + sign_in(Fabricate(:user)) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + + context "as TL3 user" do + it "forbids less then TL4 user tries to rebake" do + sign_in(Fabricate(:user, trust_level: TrustLevel[3])) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + end + + context "as TL4 user" do + it "allows TL4 users to rebake" do + sign_in(Fabricate(:user, trust_level: TrustLevel[4])) + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(200) + end + + it "does not rebake the post when channel is read_only" do + chat_message.chat_channel.update!(status: :read_only) + sign_in(Fabricate(:user, trust_level: TrustLevel[4])) + + put "/chat/#{chat_channel.id}/#{chat_message.id}/rebake.json" + expect(response.status).to eq(403) + end + end + end + end + + describe "#edit_message" do + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + + context "when current user is silenced" do + before do + UserSilencer.new(user).silence + sign_in(user) + end + + it "raises an invalid request" do + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", params: { new_message: "Hi" } + expect(response.status).to eq(403) + end + end + + it "errors when a user tries to edit another user's message" do + sign_in(Fabricate(:user)) + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", params: { new_message: "edit!" } + expect(response.status).to eq(403) + end + + it "errors when staff tries to edit another user's message" do + sign_in(admin) + new_message = "Vrroooom cars go fast" + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message, + } + expect(response.status).to eq(403) + end + + it "allows a user to edit their own messages" do + sign_in(user) + new_message = "Wow markvanlan must be a good programmer" + + put "/chat/#{chat_channel.id}/edit/#{chat_message.id}.json", + params: { + new_message: new_message, + } + expect(response.status).to eq(200) + expect(chat_message.reload.message).to eq(new_message) + end + end + + RSpec.shared_examples "chat_message_deletion" do + it "doesn't allow a user to delete another user's message" do + sign_in(other_user) + + delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" + expect(response.status).to eq(403) + end + + it "doesn't allow a silenced user to delete their message" do + sign_in(other_user) + UserSilencer.new(other_user).silence + + delete "/chat/#{other_user_message.chat_channel.id}/#{other_user_message.id}.json" + expect(response.status).to eq(403) + end + + it "Allows admin to delete others' messages" do + sign_in(admin) + + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + + it "does not allow message delete when chat channel is read_only" do + sign_in(ChatMessage.last.user) + + chat_channel.update!(status: :read_only) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.not_to change { + ChatMessage.count + } + expect(response.status).to eq(403) + + sign_in(admin) + delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" + expect(response.status).to eq(403) + end + + it "only allows admin to delete when chat channel is closed" do + sign_in(admin) + + chat_channel.update!(status: :read_only) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.not_to change { + ChatMessage.count + } + expect(response.status).to eq(403) + + chat_channel.update!(status: :closed) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + end + + describe "#delete" do + fab!(:second_user) { Fabricate(:user) } + fab!(:second_user_message) do + Fabricate(:chat_message, user: second_user, chat_channel: chat_channel) + end + + before do + ChatMessage.create(user: user, message: "this is a message", chat_channel: chat_channel) + end + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + it_behaves_like "chat_message_deletion" do + let(:other_user) { second_user } + let(:other_user_message) { second_user_message } + end + + it "Allows users to delete their own messages" do + sign_in(user) + expect { delete "/chat/#{chat_channel.id}/#{ChatMessage.last.id}.json" }.to change { + ChatMessage.count + }.by(-1) + expect(response.status).to eq(200) + end + end + end + + RSpec.shared_examples "chat_message_restoration" do + it "doesn't allow a user to restore another user's message" do + sign_in(other_user) + + put "/chat/#{chat_channel.id}/restore/#{ChatMessage.unscoped.last.id}.json" + expect(response.status).to eq(403) + end + + it "allows a user to restore their own posts" do + sign_in(user) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + + it "allows admin to restore others' posts" do + sign_in(admin) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + + it "does not allow message restore when chat channel is read_only" do + sign_in(ChatMessage.last.user) + + chat_channel.update!(status: :read_only) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + expect(deleted_message.reload.deleted_at).not_to be_nil + + sign_in(admin) + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + end + + it "only allows admin to restore when chat channel is closed" do + sign_in(admin) + + chat_channel.update!(status: :read_only) + + deleted_message = ChatMessage.unscoped.last + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(403) + expect(deleted_message.reload.deleted_at).not_to be_nil + + chat_channel.update!(status: :closed) + put "/chat/#{chat_channel.id}/restore/#{deleted_message.id}.json" + expect(response.status).to eq(200) + expect(deleted_message.reload.deleted_at).to be_nil + end + end + + describe "#restore" do + fab!(:second_user) { Fabricate(:user) } + + before do + message = + ChatMessage.create(user: user, message: "this is a message", chat_channel: chat_channel) + message.trash! + end + + describe "for category" do + fab!(:chat_channel) { Fabricate(:category_channel, chatable: category) } + + it_behaves_like "chat_message_restoration" do + let(:other_user) { second_user } + end + end + end + + describe "#update_user_last_read" do + before { sign_in(user) } + + fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) } + fab!(:message_2) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) } + + it "returns a 404 when the user is not a channel member" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(404) + end + + it "returns a 404 when the user is not following the channel" do + Fabricate( + :user_chat_channel_membership, + chat_channel: chat_channel, + user: user, + following: false, + ) + + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(404) + end + + describe "when the user is a channel member" do + fab!(:membership) do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + end + + context "when message_id param doesn't link to a message of the channel" do + it "raises a not found" do + put "/chat/#{chat_channel.id}/read/-999.json" + + expect(response.status).to eq(404) + end + end + + context "when message_id param is inferior to existing last read" do + before { membership.update!(last_read_message_id: message_2.id) } + + it "raises an invalid request" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(400) + expect(response.parsed_body["errors"][0]).to match(/message_id/) + end + end + + context "when message_id refers to deleted message" do + before { message_1.trash!(Discourse.system_user) } + + it "works" do + put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" + + expect(response.status).to eq(200) + end + end + + it "updates timing records" do + expect { put "/chat/#{chat_channel.id}/read/#{message_1.id}.json" }.not_to change { + UserChatChannelMembership.count + } + + membership.reload + expect(membership.chat_channel_id).to eq(chat_channel.id) + expect(membership.last_read_message_id).to eq(message_1.id) + expect(membership.user_id).to eq(user.id) + end + + def create_notification_and_mention_for(user, sender, msg) + Notification + .create!( + notification_type: Notification.types[:chat_mention], + user: user, + high_priority: true, + read: false, + data: { + message: "chat.mention_notification", + chat_message_id: msg.id, + chat_channel_id: msg.chat_channel_id, + chat_channel_title: msg.chat_channel.title(user), + mentioned_by_username: sender.username, + }.to_json, + ) + .tap do |notification| + ChatMention.create!(user: user, chat_message: msg, notification: notification) + end + end + + it "marks all mention notifications as read for the channel" do + notification = create_notification_and_mention_for(user, other_user, message_1) + + put "/chat/#{chat_channel.id}/read/#{message_2.id}.json" + expect(response.status).to eq(200) + expect(notification.reload.read).to eq(true) + end + + it "doesn't mark notifications of messages that weren't read yet" do + message_3 = Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) + notification = create_notification_and_mention_for(user, other_user, message_3) + + put "/chat/#{chat_channel.id}/read/#{message_2.id}.json" + + expect(response.status).to eq(200) + expect(notification.reload.read).to eq(false) + end + end + end + + describe "react" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } + fab!(:user_membership) do + Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user) + end + + fab!(:private_chat_channel) do + Fabricate(:category_channel, chatable: Fabricate(:private_category, group: Fabricate(:group))) + end + fab!(:private_chat_message) do + Fabricate(:chat_message, chat_channel: private_chat_channel, user: admin) + end + fab!(:private_user_membership) do + Fabricate(:user_chat_channel_membership, chat_channel: private_chat_channel, user: user) + end + + fab!(:chat_channel_no_memberships) { Fabricate(:category_channel) } + fab!(:chat_message_no_memberships) do + Fabricate(:chat_message, chat_channel: chat_channel_no_memberships, user: user) + end + + it "errors with invalid emoji" do + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: 12, + react_action: "add", + } + expect(response.status).to eq(400) + end + + it "errors with invalid action" do + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "sdf", + } + expect(response.status).to eq(400) + end + + it "creates a membership when reacting to channel without a membership record" do + sign_in(user) + + expect { + put "/chat/#{chat_channel_no_memberships.id}/react/#{chat_message_no_memberships.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + }.to change { UserChatChannelMembership.count }.by(1) + expect(response.status).to eq(200) + end + + it "errors when user tries to react to private channel they can't access" do + sign_in(user) + put "/chat/#{private_chat_channel.id}/react/#{private_chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + expect(response.status).to eq(403) + end + + it "errors when the user tries to react to a read_only channel" do + chat_channel.update(status: :read_only) + sign_in(user) + emoji = ":heart:" + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + }.not_to change { chat_message.reactions.where(user: user, emoji: emoji).count } + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_modify_message_disallowed", status: chat_channel.status_name), + ) + end + + it "errors when user is silenced" do + UserSilencer.new(user).silence + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":heart:", + react_action: "add", + } + expect(response.status).to eq(403) + end + + it "errors when max unique reactions limit is reached" do + Emoji + .all + .map(&:name) + .take(29) + .each { |emoji| chat_message.reactions.create(user: user, emoji: emoji) } + + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":wink:", + react_action: "add", + } + expect(response.status).to eq(200) + + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: ":wave:", + react_action: "add", + } + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.max_reactions_limit_reached"), + ) + end + + it "does not error on new duplicate reactions" do + another_user = Fabricate(:user) + Emoji + .all + .map(&:name) + .take(29) + .each { |emoji| chat_message.reactions.create(user: another_user, emoji: emoji) } + emoji = ":wink:" + chat_message.reactions.create(user: another_user, emoji: emoji) + + sign_in(user) + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + expect(response.status).to eq(200) + end + + it "adds a reaction record correctly" do + sign_in(user) + emoji = ":heart:" + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "add", + } + }.to change { chat_message.reactions.where(user: user, emoji: emoji).count }.by(1) + expect(response.status).to eq(200) + end + + it "removes a reaction record correctly" do + sign_in(user) + emoji = ":heart:" + chat_message.reactions.create(user: user, emoji: emoji) + expect { + put "/chat/#{chat_channel.id}/react/#{chat_message.id}.json", + params: { + emoji: emoji, + react_action: "remove", + } + }.to change { chat_message.reactions.where(user: user, emoji: emoji).count }.by(-1) + expect(response.status).to eq(200) + end + end + + describe "invite_users" do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: admin) } + fab!(:user2) { Fabricate(:user) } + + before do + sign_in(admin) + + [user, user2].each { |u| u.user_option.update(chat_enabled: true) } + end + + it "doesn't invite users who cannot chat" do + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:admin] + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id] } + }.not_to change { + user.notifications.where(notification_type: Notification.types[:chat_invitation]).count + } + end + + it "creates an invitation notification for users who can chat" do + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id] } + }.to change { + user.notifications.where(notification_type: Notification.types[:chat_invitation]).count + }.by(1) + end + + it "creates multiple invitations" do + expect { + put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id, user2.id] } + }.to change { + Notification.where( + notification_type: Notification.types[:chat_invitation], + user_id: [user.id, user2.id], + ).count + }.by(2) + end + + it "adds chat_message_id when param is present" do + put "/chat/#{chat_channel.id}/invite.json", + params: { + user_ids: [user.id], + chat_message_id: chat_message.id, + } + expect(JSON.parse(Notification.last.data)["chat_message_id"]).to eq(chat_message.id.to_s) + end + end + + describe "#dismiss_retention_reminder" do + it "errors for anon" do + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + expect(response.status).to eq(403) + end + + it "errors when chatable_type isn't present" do + sign_in(user) + post "/chat/dismiss-retention-reminder.json", params: {} + expect(response.status).to eq(400) + end + + it "errors when chatable_type isn't a valid option" do + sign_in(user) + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "hi" } + expect(response.status).to eq(400) + end + + it "sets `dismissed_channel_retention_reminder` to true" do + sign_in(user) + expect { + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + }.to change { user.user_option.reload.dismissed_channel_retention_reminder }.to (true) + end + + it "sets `dismissed_dm_retention_reminder` to true" do + sign_in(user) + expect { + post "/chat/dismiss-retention-reminder.json", + params: { + chatable_type: "DirectMessageChannel", + } + }.to change { user.user_option.reload.dismissed_dm_retention_reminder }.to (true) + end + + it "doesn't error if the fields are already true" do + sign_in(user) + user.user_option.update( + dismissed_channel_retention_reminder: true, + dismissed_dm_retention_reminder: true, + ) + post "/chat/dismiss-retention-reminder.json", params: { chatable_type: "Category" } + expect(response.status).to eq(200) + + post "/chat/dismiss-retention-reminder.json", + params: { + chatable_type: "DirectMessageChannel", + } + expect(response.status).to eq(200) + end + end + + describe "#quote_messages" do + fab!(:channel) { Fabricate(:category_channel, chatable: category, name: "Cool Chat") } + let(:user2) { Fabricate(:user) } + let(:message1) do + Fabricate( + :chat_message, + user: user, + chat_channel: channel, + message: "an extremely insightful response :)", + ) + end + let(:message2) do + Fabricate(:chat_message, user: user2, chat_channel: channel, message: "says you!") + end + let(:message3) { Fabricate(:chat_message, user: user, chat_channel: channel, message: "aw :(") } + + it "returns a 403 if the user can't chat" do + SiteSetting.chat_allowed_groups = nil + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(403) + end + + it "returns a 403 if the user can't see the channel" do + category.update!(read_restricted: true) + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(403) + end + + it "returns a 404 for a not found channel" do + channel.destroy + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(404) + end + + it "quotes the message ids provided" do + sign_in(user) + post "/chat/#{channel.id}/quote.json", + params: { + message_ids: [message1.id, message2.id, message3.id], + } + expect(response.status).to eq(200) + markdown = response.parsed_body["markdown"] + expect(markdown).to eq(<<~EXPECTED) + [chat quote="#{user.username};#{message1.id};#{message1.created_at.iso8601}" channel="Cool Chat" channelId="#{channel.id}" multiQuote="true" chained="true"] + an extremely insightful response :) + [/chat] + + [chat quote="#{user2.username};#{message2.id};#{message2.created_at.iso8601}" chained="true"] + says you! + [/chat] + + [chat quote="#{user.username};#{message3.id};#{message3.created_at.iso8601}" chained="true"] + aw :( + [/chat] + EXPECTED + end + end + + describe "#flag" do + fab!(:admin_chat_message) { Fabricate(:chat_message, user: admin, chat_channel: chat_channel) } + fab!(:user_chat_message) { Fabricate(:chat_message, user: user, chat_channel: chat_channel) } + + fab!(:admin_dm_message) { Fabricate(:chat_message, user: admin, chat_channel: dm_chat_channel) } + + before do + sign_in(user) + Group.refresh_automatic_groups! + end + + it "creates reviewable" do + expect { + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + }.to change { ReviewableChatMessage.where(target: admin_chat_message).count }.by(1) + expect(response.status).to eq(200) + end + + it "errors for silenced users" do + UserSilencer.new(user).silence + + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "doesn't allow flagging your own message" do + put "/chat/flag.json", + params: { + chat_message_id: user_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "doesn't allow flagging messages in a read_only channel" do + user_chat_message.chat_channel.update(status: :read_only) + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + + expect(response.status).to eq(403) + end + + it "doesn't allow flagging staff if SiteSetting.allow_flagging_staff is false" do + SiteSetting.allow_flagging_staff = false + put "/chat/flag.json", + params: { + chat_message_id: admin_chat_message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(403) + end + + it "returns a 429 when the user attempts to flag more than 4 messages in 1 minute" do + RateLimiter.enable + + [message_1, message_2, message_3, message_4].each do |message| + put "/chat/flag.json", + params: { + chat_message_id: message.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + expect(response.status).to eq(200) + end + + put "/chat/flag.json", + params: { + chat_message_id: message_5.id, + flag_type_id: ReviewableScore.types[:off_topic], + } + + expect(response.status).to eq(429) + end + end + + describe "#set_draft" do + fab!(:chat_channel) { Fabricate(:category_channel) } + let(:dm_channel) { Fabricate(:dm_channel) } + + before { sign_in(user) } + + it "can create and destroy chat drafts" do + expect { + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + + expect { post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id } }.to change { + ChatDraft.count + }.by(-1) + end + + it "cannot create chat drafts for a category channel the user cannot access" do + group = Fabricate(:group) + private_category = Fabricate(:private_category, group: group) + chat_channel.update!(chatable: private_category) + + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + expect(response.status).to eq(403) + + GroupUser.create!(user: user, group: group) + expect { + post "/chat/drafts.json", params: { chat_channel_id: chat_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + end + + it "cannot create chat drafts for a direct message channel the user cannot access" do + post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } + expect(response.status).to eq(403) + + DirectMessageUser.create(user: user, direct_message_channel: dm_channel.chatable) + expect { + post "/chat/drafts.json", params: { chat_channel_id: dm_channel.id, data: "{}" } + }.to change { ChatDraft.count }.by(1) + end + end + + describe "#message_link" do + it "ensures message's channel can be seen" do + channel = Fabricate(:category_channel, chatable: Fabricate(:category)) + message = Fabricate(:chat_message, chat_channel: channel) + + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + + sign_in(Fabricate(:user)) + get "/chat/message/#{message.id}.json" + end + end + + describe "#lookup_message" do + let!(:message) { Fabricate(:chat_message, chat_channel: channel) } + let(:channel) { Fabricate(:dm_channel) } + let(:chatable) { channel.chatable } + fab!(:user) { Fabricate(:user) } + + before { sign_in(user) } + + it "ensures message's channel can be seen" do + Guardian.any_instance.expects(:can_see_chat_channel?).with(channel) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + end + + context "when the message doesn’t belong to the channel" do + let!(:message) { Fabricate(:chat_message) } + + it "returns a 404" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + + expect(response.status).to eq(404) + end + end + + context "when the chat channel is for a category" do + let(:channel) { Fabricate(:category_channel) } + + it "ensures the user can access that category" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + + group = Fabricate(:group) + chatable.update!(read_restricted: true) + Fabricate(:category_group, group: group, category: chatable) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(403) + + GroupUser.create!(user: user, group: group) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + end + end + + context "when the chat channel is for a direct message channel" do + let(:channel) { Fabricate(:dm_channel) } + + it "ensures the user can access that direct message channel" do + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(403) + + DirectMessageUser.create!(user: user, direct_message_channel: chatable) + get "/chat/lookup/#{message.id}.json", { params: { chat_channel_id: channel.id } } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_messages"][0]["id"]).to eq(message.id) + end + end + end + + describe "#move_messages_to_channel" do + fab!(:message_to_move1) do + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "some cool message", + created_at: 2.minutes.ago, + ) + end + fab!(:message_to_move2) do + Fabricate( + :chat_message, + chat_channel: chat_channel, + message: "and another thing", + created_at: 1.minute.ago, + ) + end + fab!(:destination_channel) { Fabricate(:category_channel) } + let(:message_ids) { [message_to_move1.id, message_to_move2.id] } + let(:invalid_destination_channel) do + Fabricate( + :dm_channel, + chatable: Fabricate(:direct_message_channel, users: [admin, Fabricate(:user)]), + ) + end + + context "when the user is not admin" do + it "returns an access denied error" do + sign_in(user) + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(403) + end + end + + context "when the user is admin" do + before { sign_in(admin) } + + it "shows an error if the source channel is not found" do + chat_channel.trash! + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(404) + end + + it "shows an error if the destination channel is not found" do + destination_channel.trash! + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(404) + end + + it "successfully moves the messages to the new channel" do + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(200) + latest_destination_messages = destination_channel.chat_messages.last(2) + expect(latest_destination_messages.first.message).to eq("some cool message") + expect(latest_destination_messages.second.message).to eq("and another thing") + expect(message_to_move1.reload.deleted_at).not_to eq(nil) + expect(message_to_move2.reload.deleted_at).not_to eq(nil) + end + + it "shows an error message when the destination channel is invalid" do + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: invalid_destination_channel.id, + message_ids: message_ids, + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.message_move_invalid_channel"), + ) + end + + it "shows an error when none of the messages can be found" do + destroyed_message = Fabricate(:chat_message, chat_channel: chat_channel) + destroyed_message.trash! + + put "/chat/#{chat_channel.id}/move_messages_to_channel.json", + params: { + destination_channel_id: destination_channel.id, + message_ids: [destroyed_message], + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.message_move_no_messages_found"), + ) + end + end + end +end diff --git a/plugins/chat/spec/requests/direct_messages_controller_spec.rb b/plugins/chat/spec/requests/direct_messages_controller_spec.rb new file mode 100644 index 00000000000..92e785a97f6 --- /dev/null +++ b/plugins/chat/spec/requests/direct_messages_controller_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::DirectMessagesController do + fab!(:user) { Fabricate(:user) } + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + sign_in(user) + end + + def create_dm_channel(user_ids) + direct_messages_channel = DirectMessageChannel.create! + user_ids.each do |user_id| + direct_messages_channel.direct_message_users.create!(user_id: user_id) + end + DMChannel.create!(chatable: direct_messages_channel) + end + + describe "#index" do + context "when user is not allowed to chat" do + before { SiteSetting.chat_allowed_groups = nil } + + it "returns a forbidden error" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(403) + end + end + + context "when channel doesn’t exists" do + it "returns a not found error" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(404) + end + end + + context "when channel exists" do + let!(:channel) do + direct_messages_channel = DirectMessageChannel.create! + direct_messages_channel.direct_message_users.create!(user_id: user.id) + direct_messages_channel.direct_message_users.create!(user_id: user1.id) + DMChannel.create!(chatable: direct_messages_channel) + end + + it "returns the channel" do + get "/chat/direct_messages.json", params: { usernames: user1.username } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_channel"]["id"]).to eq(channel.id) + end + + context "with more than two users" do + fab!(:user3) { Fabricate(:user) } + before { channel.chatable.direct_message_users.create!(user_id: user3.id) } + + it "returns the channel" do + get "/chat/direct_messages.json", + params: { + usernames: [user1.username, user.username, user3.username].join(","), + } + expect(response.status).to eq(200) + expect(response.parsed_body["chat_channel"]["id"]).to eq(channel.id) + end + end + end + end + + describe "#create" do + before { Group.refresh_automatic_groups! } + + shared_examples "creating dms" do + it "creates a new dm channel with username(s) provided" do + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.to change { DirectMessageChannel.count }.by(1) + expect(DirectMessageChannel.last.direct_message_users.map(&:user_id)).to match_array( + direct_message_user_ids, + ) + end + + it "returns existing dm channel if one exists for username(s)" do + create_dm_channel(direct_message_user_ids) + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.not_to change { DirectMessageChannel.count } + end + end + + describe "dm with one other user" do + let(:usernames) { user1.username } + let(:direct_message_user_ids) { [user.id, user1.id] } + + include_examples "creating dms" + end + + describe "dm with myself" do + let(:usernames) { [user.username] } + let(:direct_message_user_ids) { [user.id] } + + include_examples "creating dms" + end + + describe "dm with two other users" do + let(:usernames) { [user1, user2, user3].map(&:username) } + let(:direct_message_user_ids) { [user.id, user1.id, user2.id, user3.id] } + + include_examples "creating dms" + end + + it "creates UserChatChannelMembership records" do + users = [user2, user3] + usernames = users.map(&:username) + expect { + post "/chat/direct_messages/create.json", params: { usernames: usernames } + }.to change { UserChatChannelMembership.count }.by(3) + end + + context "when one of the users I am messaging has ignored, muted, or prevented DMs from the acting user creating the channel" do + let(:usernames) { [user1, user2, user3].map(&:username) } + let(:direct_message_user_ids) { [user.id, user1.id, user2.id, user3.id] } + + shared_examples "creating dms with communication error" do + it "responds with a friendly error" do + expect { + post "/chat/direct_messages/create.json", params: { usernames: [usernames] } + }.not_to change { DirectMessageChannel.count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq( + [I18n.t("chat.errors.not_accepting_dms", username: user1.username)], + ) + end + end + + describe "user ignoring the actor" do + before do + Fabricate(:ignored_user, user: user1, ignored_user: user, expiring_at: 1.day.from_now) + end + + include_examples "creating dms with communication error" + end + + describe "user muting the actor" do + before { Fabricate(:muted_user, user: user1, muted_user: user) } + + include_examples "creating dms with communication error" + end + + describe "user preventing all DMs" do + before { user1.user_option.update(allow_private_messages: false) } + + include_examples "creating dms with communication error" + end + + describe "user only allowing DMs from certain users" do + before { user1.user_option.update(enable_allowed_pm_users: true) } + + include_examples "creating dms with communication error" + end + end + end +end diff --git a/plugins/chat/spec/requests/email_controller_spec.rb b/plugins/chat/spec/requests/email_controller_spec.rb new file mode 100644 index 00000000000..54546347034 --- /dev/null +++ b/plugins/chat/spec/requests/email_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe EmailController do + describe "unsubscribing from chat email settings" do + fab!(:user) { Fabricate(:user) } + + it "updates an user chat summary frequency" do + SiteSetting.chat_enabled = true + never_freq = "never" + key = UnsubscribeKey.create_key_for(user, "chat_summary") + user.user_option.send_chat_email_when_away! + + post "/email/unsubscribe/#{key}.json", params: { chat_email_frequency: never_freq } + + expect(response.status).to eq(302) + + get response.redirect_url + + expect(body).to include(user.email) + expect(user.user_option.reload.chat_email_frequency).to eq(never_freq) + end + end +end diff --git a/plugins/chat/spec/requests/emojis_controller_spec.rb b/plugins/chat/spec/requests/emojis_controller_spec.rb new file mode 100644 index 00000000000..e193411f7c0 --- /dev/null +++ b/plugins/chat/spec/requests/emojis_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::EmojisController do + fab!(:user_1) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + sign_in(user_1) + end + + describe "#index" do + before do + CustomEmoji.destroy_all + CustomEmoji.create!(name: "cat", upload: Fabricate(:upload)) + Emoji.clear_cache + end + + after do + CustomEmoji.destroy_all + Emoji.clear_cache + end + + it "returns the emojis list" do + get "/chat/emojis.json" + + expect(response.status).to eq(200) + expect(response.parsed_body.keys).to eq( + %w[ + smileys_&_emotion + people_&_body + objects + travel_&_places + animals_&_nature + food_&_drink + activities + flags + symbols + default + ], + ) + end + end +end diff --git a/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb b/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb new file mode 100644 index 00000000000..448230c2b83 --- /dev/null +++ b/plugins/chat/spec/requests/incoming_chat_webhooks_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::IncomingChatWebhooksController do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:webhook) { Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) } + + before { SiteSetting.chat_debug_webhook_payloads = true } + + describe "#create_message" do + it "errors with invalid key" do + post "/chat/hooks/null.json" + expect(response.status).to eq(400) + end + + it "errors when no body is present" do + post "/chat/hooks/#{webhook.key}.json" + expect(response.status).to eq(400) + end + + it "errors when the body is over WEBHOOK_MAX_MESSAGE_LENGTH characters" do + post "/chat/hooks/#{webhook.key}.json", + params: { + text: "$" * (Chat::IncomingChatWebhooksController::WEBHOOK_MAX_MESSAGE_LENGTH + 1), + } + expect(response.status).to eq(400) + end + + it "creates a new chat message" do + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + chat_webhook_event = ChatWebhookEvent.last + expect(chat_webhook_event.chat_message_id).to eq(ChatMessage.last.id) + end + + it "handles create message failures gracefully and does not create the chat message" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "hey #{watched_word.word}" } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + "Sorry, you can't post the word '#{watched_word.word}'; it's not allowed.", + ) + end + + it "handles create message failures gracefully if the channel is read only" do + chat_channel.update!(status: :read_only) + expect { + post "/chat/hooks/#{webhook.key}.json", params: { text: "hey this is a message" } + }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("chat.errors.channel_new_message_disallowed", status: chat_channel.status_name), + ) + end + + it "rate limits" do + RateLimiter.enable + RateLimiter.clear_all! + 10.times { post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } } + expect(response.status).to eq(200) + + post "/chat/hooks/#{webhook.key}.json", params: { text: "A new signup woo!" } + expect(response.status).to eq(429) + end + end + + describe "#create_message_slack_compatible" do + it "processes the text param with SlackCompatibility" do + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { text: "A new signup woo !" } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(response.status).to eq(200) + expect(ChatMessage.last.message).to eq("A new signup woo @here!") + end + + it "processes the attachments param with SlackCompatibility, using the fallback" do + payload_data = { + attachments: [ + { + color: "#F4511E", + title: "New+alert:+#46353", + text: + "\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"", + fallback: + "New+alert:+\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"+\nTags:+", + title_link: "https://eu.opsg.in/a/i/test/blahguid", + }, + ], + } + expect { post "/chat/hooks/#{webhook.key}/slack.json", params: payload_data }.to change { + ChatMessage.where(chat_channel: chat_channel).count + }.by(1) + expect(ChatMessage.last.message).to eq( + "New alert: \"[StatusCake] https://www.test_notification.com (StatusCake Test Alert): Down,\" [46353](https://eu.opsg.in/a/i/test/blahguid)\nTags: ", + ) + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { payload: payload_data } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + end + + it "can process the payload when it's a JSON string" do + payload_data = { + attachments: [ + { + color: "#F4511E", + title: "New+alert:+#46353", + text: + "\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"", + fallback: + "New+alert:+\"[StatusCake]+https://www.test_notification.com+(StatusCake+Test+Alert):+Down,\"+\nTags:+", + title_link: "https://eu.opsg.in/a/i/test/blahguid", + }, + ], + } + expect { + post "/chat/hooks/#{webhook.key}/slack.json", params: { payload: payload_data.to_json } + }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + expect(ChatMessage.last.message).to eq( + "New alert: \"[StatusCake] https://www.test_notification.com (StatusCake Test Alert): Down,\" [46353](https://eu.opsg.in/a/i/test/blahguid)\nTags: ", + ) + end + end +end diff --git a/plugins/chat/spec/requests/users_controller_spec.rb b/plugins/chat/spec/requests/users_controller_spec.rb new file mode 100644 index 00000000000..4b518fda752 --- /dev/null +++ b/plugins/chat/spec/requests/users_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +describe UsersController do + describe "#perform_account_activation" do + let!(:channel) { Fabricate(:category_channel, auto_join_users: true) } + + before do + Jobs.run_immediately! + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) + SiteSetting.send_welcome_message = false + SiteSetting.chat_enabled = true + end + + it "triggers the auto-join process" do + user = Fabricate(:user, last_seen_at: 1.minute.ago, active: false) + email_token = Fabricate(:email_token, user: user) + + put "/u/activate-account/#{email_token.token}" + + expect(response.status).to eq(200) + membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + expect(membership.following).to eq(true) + end + end +end diff --git a/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb b/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb new file mode 100644 index 00000000000..4a164ae8720 --- /dev/null +++ b/plugins/chat/spec/serializer/chat_channel_serializer_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatChannelSerializer do + fab!(:user) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + fab!(:chat_channel) { Fabricate(:chat_channel) } + let(:guardian_user) { user } + let(:guardian) { Guardian.new(guardian_user) } + subject { described_class.new(chat_channel, scope: guardian, root: nil) } + + describe "archive status" do + context "when user is not staff" do + let(:guardian_user) { user } + + it "does not return any sort of archive status" do + expect(subject.as_json.key?(:archive_completed)).to eq(false) + end + end + + context "when user is staff" do + let(:guardian_user) { admin } + + it "includes the archive status if the channel is archived and the archive record exists" do + expect(subject.as_json.key?(:archive_completed)).to eq(false) + + chat_channel.update!(status: ChatChannel.statuses[:archived]) + expect(subject.as_json.key?(:archive_completed)).to eq(false) + + ChatChannelArchive.create!( + chat_channel: chat_channel, + archived_by: admin, + destination_topic_title: "This will be the archive topic", + total_messages: 10, + ) + chat_channel.reload + expect(subject.as_json.key?(:archive_completed)).to eq(true) + end + end + end +end diff --git a/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb b/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb new file mode 100644 index 00000000000..bf873d90717 --- /dev/null +++ b/plugins/chat/spec/serializer/chat_in_reply_to_serializer_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChatInReplyToSerializer do + subject(:serializer) { described_class.new(message, scope: guardian, root: nil) } + + fab!(:chat_channel) { Fabricate(:chat_channel) } + let(:guardian) { Guardian.new(Fabricate(:user)) } + + describe "#user" do + let(:message) { Fabricate(:chat_message, user: Fabricate(:user), chat_channel: chat_channel) } + + context "when user has been destroyed" do + before do + message.user.destroy! + message.reload + end + + it "returns a placeholder user" do + expect(serializer.as_json[:user][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end + + describe "#excerpt" do + let(:watched_word) { Fabricate(:watched_word, action: WatchedWord.actions[:censor]) } + let(:message) { Fabricate(:chat_message, message: "ok #{watched_word.word}") } + + it "censors words" do + expect(serializer.as_json[:excerpt]).to eq("ok ■■■■■") + end + end +end diff --git a/plugins/chat/spec/serializer/chat_message_serializer_spec.rb b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb new file mode 100644 index 00000000000..bad12661f53 --- /dev/null +++ b/plugins/chat/spec/serializer/chat_message_serializer_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatMessageSerializer do + fab!(:chat_channel) { Fabricate(:category_channel) } + fab!(:message_poster) { Fabricate(:user) } + fab!(:message_1) { Fabricate(:chat_message, user: message_poster, chat_channel: chat_channel) } + fab!(:guardian_user) { Fabricate(:user) } + let(:guardian) { Guardian.new(guardian_user) } + + subject { described_class.new(message_1, scope: guardian, root: nil) } + + describe "#reactions" do + fab!(:custom_emoji) { CustomEmoji.create!(name: "trout", upload: Fabricate(:upload)) } + fab!(:reaction_1) do + Fabricate(:chat_message_reaction, chat_message: message_1, emoji: custom_emoji.name) + end + + context "when an emoji used in a reaction has been destroyed" do + it "doesn’t return the reaction" do + Emoji.clear_cache + + expect(subject.as_json[:reactions]["trout"]).to be_present + + custom_emoji.destroy! + Emoji.clear_cache + + expect(subject.as_json[:reactions]["trout"]).to_not be_present + end + end + end + + describe "#excerpt" do + it "censors words" do + watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) + message = Fabricate(:chat_message, message: "ok #{watched_word.word}") + serializer = described_class.new(message, scope: guardian, root: nil) + + expect(serializer.as_json[:excerpt]).to eq("ok ■■■■■") + end + end + + describe "#user" do + context "when user has been destroyed" do + it "returns a placeholder user" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:user][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end + + describe "#deleted_at" do + context "when user has been destroyed" do + it "has a deleted at date" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:deleted_at]).to(be_within(1.second).of(Time.zone.now)) + end + + it "is marked as deleted by system user" do + message_1.user.destroy! + message_1.reload + + expect(subject.as_json[:deleted_by_id]).to eq(Discourse.system_user.id) + end + end + end + + describe "#available_flags" do + before { Group.refresh_automatic_groups! } + + context "when flagging on a regular channel" do + let(:options) { { scope: guardian, root: nil, chat_channel: message_1.chat_channel } } + + it "returns an empty list if the user already flagged the message" do + reviewable = Fabricate(:reviewable_chat_message, target: message_1) + + serialized = + described_class.new( + message_1, + options.merge( + reviewable_ids: { + message_1.id => reviewable.id, + }, + user_flag_statuses: { + message_1.id => ReviewableScore.statuses[:pending], + }, + ), + ).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "return available flags if staff already reviewed the previous flag" do + reviewable = Fabricate(:reviewable_chat_message, target: message_1) + + serialized = + described_class.new( + message_1, + options.merge( + reviewable_ids: { + message_1.id => reviewable.id, + }, + user_flag_statuses: { + message_1.id => ReviewableScore.statuses[:ignored], + }, + ), + ).as_json + + expect(serialized[:available_flags]).to be_present + end + + it "doesn't include notify_user for self-flags" do + guardian_1 = Guardian.new(message_1.user) + + serialized = + described_class.new(message_1, options.merge(scope: Guardian.new(message_poster))).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "doesn't include the notify_user flag for bot messages" do + message_1.update!(user: Discourse.system_user) + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "returns an empty list for anons" do + serialized = described_class.new(message_1, options.merge(scope: Guardian.new)).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "returns an empty list for silenced users" do + guardian.user.update!(silenced_till: 1.month.from_now) + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "returns an empty list if the message was deleted" do + message_1.trash! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + + it "doesn't include notify_user if they are not in a PM allowed group" do + SiteSetting.personal_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] + Group.refresh_automatic_groups! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "returns an empty list if the user needs a higher TL to flag" do + guardian.user.update!(trust_level: TrustLevel[2]) + SiteSetting.chat_message_flag_allowed_groups = Group::AUTO_GROUPS[:trust_level_3] + Group.refresh_automatic_groups! + + serialized = described_class.new(message_1, options).as_json + + expect(serialized[:available_flags]).to be_empty + end + end + + context "when flagging DMs" do + fab!(:dm_channel) do + Fabricate(:direct_message_chat_channel, users: [guardian_user, message_poster]) + end + fab!(:dm_message) { Fabricate(:chat_message, user: message_poster, chat_channel: dm_channel) } + + let(:options) { { scope: guardian, root: nil, chat_channel: dm_channel } } + + it "doesn't include the notify_user flag type" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_user) + end + + it "doesn't include the notify_moderators flag type" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).not_to include(:notify_moderators) + end + + it "includes other flags" do + serialized = described_class.new(dm_message, options).as_json + + expect(serialized[:available_flags]).to include(:spam) + end + + it "fallbacks to the object association when the chat_channel option is nil" do + serialized = described_class.new(dm_message, options.except(:chat_channel)).as_json + + expect(serialized[:available_flags]).not_to include(:notify_moderators) + end + end + end +end diff --git a/plugins/chat/spec/serializer/direct_message_channel_serializer_spec.rb b/plugins/chat/spec/serializer/direct_message_channel_serializer_spec.rb new file mode 100644 index 00000000000..9b95f0737d0 --- /dev/null +++ b/plugins/chat/spec/serializer/direct_message_channel_serializer_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DirectMessageChannelSerializer do + describe "#user" do + it "returns you when there are two of us" do + me = Fabricate.build(:user) + you = Fabricate.build(:user) + direct_message_channel = Fabricate.build(:direct_message_channel, users: [me, you]) + + serializer = + DirectMessageChannelSerializer.new( + direct_message_channel, + scope: Guardian.new(me), + root: false, + ) + + expect(serializer.users).to eq([you]) + end + + it "returns you both if there are three of us" do + me = Fabricate.build(:user) + you = Fabricate.build(:user) + other_you = Fabricate.build(:user) + direct_message_channel = Fabricate.build(:direct_message_channel, users: [me, you, other_you]) + + serializer = + DirectMessageChannelSerializer.new( + direct_message_channel, + scope: Guardian.new(me), + root: false, + ) + + expect(serializer.users).to match_array([you, other_you]) + end + + it "returns me if there is only me" do + me = Fabricate.build(:user) + direct_message_channel = Fabricate.build(:direct_message_channel, users: [me]) + + serializer = + DirectMessageChannelSerializer.new( + direct_message_channel, + scope: Guardian.new(me), + root: false, + ) + + expect(serializer.users).to eq([me]) + end + + context "when a user is destroyed" do + it "returns a placeholder user" do + me = Fabricate(:user) + you = Fabricate(:user) + direct_message_channel = Fabricate(:direct_message_channel, users: [me, you]) + + you.destroy! + + serializer = + DirectMessageChannelSerializer.new( + direct_message_channel.reload, + scope: Guardian.new(me), + root: false, + ).as_json + + expect(serializer[:users][0][:username]).to eq(I18n.t("chat.deleted_chat_username")) + end + end + end +end diff --git a/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb b/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb new file mode 100644 index 00000000000..f0956df5473 --- /dev/null +++ b/plugins/chat/spec/serializer/structured_channel_serializer_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +RSpec.describe StructuredChannelSerializer do + fab!(:user1) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user1) } + fab!(:user2) { Fabricate(:user) } + fab!(:user3) { Fabricate(:user) } + fab!(:channel1) { Fabricate(:chat_channel) } + fab!(:channel2) { Fabricate(:chat_channel) } + fab!(:channel3) do + Fabricate(:chat_channel, chatable: Fabricate(:direct_message_channel, users: [user1, user2])) + end + fab!(:channel4) do + Fabricate(:chat_channel, chatable: Fabricate(:direct_message_channel, users: [user1, user3])) + end + fab!(:membership1) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel1) + end + fab!(:membership2) do + Fabricate(:user_chat_channel_membership, user: user1, chat_channel: channel2) + end + fab!(:membership3) do + Fabricate(:user_chat_channel_membership_for_dm, user: user1, chat_channel: channel3) + end + fab!(:membership4) do + Fabricate(:user_chat_channel_membership_for_dm, user: user1, chat_channel: channel4) + end + fab!(:membership5) do + Fabricate(:user_chat_channel_membership_for_dm, user: user2, chat_channel: channel3) + end + fab!(:membership6) do + Fabricate(:user_chat_channel_membership_for_dm, user: user3, chat_channel: channel4) + end + + def fetch_data + Chat::ChatChannelFetcher.structured(guardian) + end + + it "serializes a public channel correctly with membership embedded" do + expect( + described_class + .new(fetch_data, scope: guardian) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to include( + "chat_channel_id" => channel1.id, + "desktop_notification_level" => "mention", + "following" => true, + "last_read_message_id" => nil, + "mobile_notification_level" => "mention", + "muted" => false, + "unread_count" => 0, + "unread_mentions" => 0, + ) + end + + it "serializes a direct message channel correctly with membership embedded" do + expect( + described_class + .new(fetch_data, scope: guardian) + .direct_message_channels + .find { |channel| channel.id == channel3.id } + .current_user_membership + .as_json, + ).to include( + "chat_channel_id" => channel3.id, + "desktop_notification_level" => "always", + "following" => true, + "last_read_message_id" => nil, + "mobile_notification_level" => "always", + "muted" => false, + "unread_count" => 0, + "unread_mentions" => 0, + ) + end + + it "does not include membership details for an anonymous user" do + expect( + described_class + .new(fetch_data, scope: Guardian.new) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to eq(nil) + end + + it "does not include membership if somehow the data is missing" do + data = fetch_data + data[:memberships] = data[:memberships].reject do |membership| + membership.chat_channel_id == channel1.id + end + expect( + described_class + .new(data, scope: guardian) + .public_channels + .find { |channel| channel.id == channel1.id } + .current_user_membership + .as_json, + ).to eq(nil) + end +end diff --git a/plugins/chat/spec/services/chat_publisher_spec.rb b/plugins/chat/spec/services/chat_publisher_spec.rb new file mode 100644 index 00000000000..3416170a329 --- /dev/null +++ b/plugins/chat/spec/services/chat_publisher_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatPublisher do + fab!(:channel) { Fabricate(:category_channel) } + fab!(:message) { Fabricate(:chat_message, chat_channel: channel) } + + describe ".publish_refresh!" do + it "publishes the message" do + data = MessageBus.track_publish { ChatPublisher.publish_refresh!(channel, message) }[0].data + + expect(data["chat_message"]["id"]).to eq(message.id) + expect(data["type"]).to eq("refresh") + end + end +end diff --git a/plugins/chat/spec/support/api/schemas/category_chat_channel.json b/plugins/chat/spec/support/api/schemas/category_chat_channel.json new file mode 100644 index 00000000000..a0905eab33f --- /dev/null +++ b/plugins/chat/spec/support/api/schemas/category_chat_channel.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "additionalProperties": { + "auto_join_users": { "type": "boolean" } + }, + "properties": { + "id": { "type": "number" }, + "chatable_type": { "type": "string" }, + "chatable_url": { "type": "string" }, + "title": { "type": "string" }, + "chatable_id": { "type": "number" }, + "last_message_sent_at": { "type": "string" }, + "status": { "type": "string" }, + "chatable": { + "type": "object", + "required": ["id", "name", "slug", "color"] + }, + "current_user_membership": { + "type": ["object", "null"], + "properties": { + "last_read_message_id": { "type": ["number", "null"] }, + "muted": { "type": "boolean" }, + "unread_count": { "type": "number" }, + "unread_mentions": { "type": "number" }, + "desktop_notification_level": { "type": "string" }, + "mobile_notification_level": { "type": "string" }, + "following": { "type": "boolean" } + } + } + } +} diff --git a/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json new file mode 100644 index 00000000000..30725b7d446 --- /dev/null +++ b/plugins/chat/spec/support/api/schemas/user_chat_channel_membership.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": [ + "chat_channel_id", + "last_read_message_id", + "muted", + "desktop_notification_level", + "mobile_notification_level", + "following" + ], + "properties": { + "chat_channel_id": { "type": "number" }, + "last_read_message_id": { "type": ["number", "null"] }, + "muted": { "type": "boolean" }, + "desktop_notification_level": { "type": "string" }, + "mobile_notification_level": { "type": "string" }, + "following": { "type": "boolean" }, + "unread_count": { "type": "number" }, + "unread_mentions": { "type": "number" }, + "user": { + "type": ["object", "null"], + "required": ["id", "name", "avatar_template", "username"], + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "avatar_template": { "type": "string" }, + "username": { "type": "string" } + } + } + } +} diff --git a/plugins/chat/spec/support/api_schema_matcher.rb b/plugins/chat/spec/support/api_schema_matcher.rb new file mode 100644 index 00000000000..e78e158d79c --- /dev/null +++ b/plugins/chat/spec/support/api_schema_matcher.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :match_response_schema do |schema| + match do |object| + schema_directory = "#{Dir.pwd}/plugins/chat/spec/support/api/schemas" + schema_path = "#{schema_directory}/#{schema}.json" + + begin + JSON::Validator.validate!(schema_path, object, strict: true) + rescue JSON::Schema::ValidationError => e + puts "-- Printing response body after validation error\n" + pp object + raise e + end + end +end diff --git a/plugins/chat/spec/support/chat_helper.rb b/plugins/chat/spec/support/chat_helper.rb new file mode 100644 index 00000000000..9a093be4368 --- /dev/null +++ b/plugins/chat/spec/support/chat_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ChatHelper + def self.make_messages!(chatable, users, count) + users = [users] unless Array === users + raise ArgumentError if users.length <= 0 + + chatable = Fabricate(:category) unless chatable + chat_channel = Fabricate(:chat_channel, chatable: chatable) + + count.times do |n| + ChatMessage.new( + chat_channel: chat_channel, + user: users[n % users.length], + message: "Chat message for test #{n}", + ).save! + end + end +end diff --git a/plugins/chat/spec/support/examples/channel_access_example.rb b/plugins/chat/spec/support/examples/channel_access_example.rb new file mode 100644 index 00000000000..4a242bcd8c1 --- /dev/null +++ b/plugins/chat/spec/support/examples/channel_access_example.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples "channel access example" do |verb, endpoint| + endpoint ||= ".json" + + context "when channel is not found" do + before { sign_in(Fabricate(:admin)) } + + it "returns a 404" do + public_send(verb, "/chat/api/chat_channels/-999#{endpoint}") + expect(response.status).to eq(404) + end + end + + context "with anonymous user" do + fab!(:chat_channel) { Fabricate(:category_channel) } + + it "returns a 403" do + public_send(verb, "/chat/api/chat_channels/#{chat_channel.id}#{endpoint}") + expect(response.status).to eq(403) + end + end + + context "when channel can’t be seen by current user" do + fab!(:chatable) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:chat_channel) { Fabricate(:category_channel, chatable: chatable) } + fab!(:user) { Fabricate(:user) } + fab!(:membership) do + Fabricate(:user_chat_channel_membership, user: user, chat_channel: chat_channel) + end + + before { sign_in(user) } + + it "returns a 403" do + public_send(verb, "/chat/api/chat_channels/#{chat_channel.id}#{endpoint}") + expect(response.status).to eq(403) + end + end +end diff --git a/plugins/chat/spec/support/examples/chat_channel_model.rb b/plugins/chat/spec/support/examples/chat_channel_model.rb new file mode 100644 index 00000000000..ed2f1a9aabd --- /dev/null +++ b/plugins/chat/spec/support/examples/chat_channel_model.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a chat channel model" do + fab!(:user1) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:staff) { Fabricate(:user, admin: true) } + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + fab!(:private_category_channel) { Fabricate(:category_channel, chatable: private_category) } + fab!(:direct_message_channel) do + Fabricate(:dm_channel, chatable: Fabricate(:direct_message_channel, users: [user1, user2])) + end + + it { is_expected.to belong_to(:chatable) } + it { is_expected.to belong_to(:direct_message_channel).with_foreign_key(:chatable_id) } + it { is_expected.to have_many(:chat_messages) } + it { is_expected.to have_many(:user_chat_channel_memberships) } + it { is_expected.to have_one(:chat_channel_archive) } + it do + is_expected.to define_enum_for(:status).with_values( + open: 0, + read_only: 1, + closed: 2, + archived: 3, + ).without_scopes + end + + describe "Validations" do + it { is_expected.to validate_presence_of(:name).allow_nil } + it do + is_expected.to validate_length_of(:name).is_at_most( + SiteSetting.max_topic_title_length, + ).allow_nil + end + end + + describe ".public_channels" do + context "when a category used as chatable is destroyed" do + fab!(:category_channel_1) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + fab!(:category_channel_2) { Fabricate(:chat_channel, chatable: Fabricate(:category)) } + + before { category_channel_1.chatable.destroy! } + + it "doesn’t list the channel" do + ids = ChatChannel.public_channels.pluck(:chatable_id) + expect(ids).to_not include(category_channel_1.chatable_id) + expect(ids).to include(category_channel_2.chatable_id) + end + end + end + + describe "#closed!" do + before { private_category_channel.update!(status: :open) } + + it "does nothing if user is not staff" do + private_category_channel.closed!(user1) + expect(private_category_channel.reload.open?).to eq(true) + end + + it "closes the channel, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.closed!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [{ channel: private_category_channel, old_status: "open", new_status: "closed" }], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "closed" }, + ) + expect(private_category_channel.reload.closed?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :closed, + previous_value: :open, + ), + ).to eq(true) + end + end + + describe "#open!" do + before { private_category_channel.update!(status: :closed) } + + it "does nothing if user is not staff" do + private_category_channel.open!(user1) + expect(private_category_channel.reload.closed?).to eq(true) + end + + it "does nothing if the channel is archived" do + private_category_channel.update!(status: :archived) + private_category_channel.open!(staff) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "opens the channel, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.open!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [{ channel: private_category_channel, old_status: "closed", new_status: "open" }], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "open" }, + ) + expect(private_category_channel.reload.open?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :open, + previous_value: :closed, + ), + ).to eq(true) + end + end + + describe "#read_only!" do + before { private_category_channel.update!(status: :open) } + + it "does nothing if user is not staff" do + private_category_channel.read_only!(user1) + expect(private_category_channel.reload.open?).to eq(true) + end + + it "marks the channel read_only, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.read_only!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [ + { channel: private_category_channel, old_status: "open", new_status: "read_only" }, + ], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "read_only" }, + ) + expect(private_category_channel.reload.read_only?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :read_only, + previous_value: :open, + ), + ).to eq(true) + end + end + + describe "#archived!" do + before { private_category_channel.update!(status: :read_only) } + + it "does nothing if user is not staff" do + private_category_channel.archived!(user1) + expect(private_category_channel.reload.read_only?).to eq(true) + end + + it "does nothing if already archived" do + private_category_channel.update!(status: :archived) + private_category_channel.archived!(user1) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "does nothing if the channel is not already readonly" do + private_category_channel.update!(status: :open) + private_category_channel.archived!(staff) + expect(private_category_channel.reload.open?).to eq(true) + private_category_channel.update!(status: :read_only) + private_category_channel.archived!(staff) + expect(private_category_channel.reload.archived?).to eq(true) + end + + it "marks the channel archived, logs a staff action, and sends an event" do + events = [] + messages = + MessageBus.track_publish do + events = DiscourseEvent.track_events { private_category_channel.archived!(staff) } + end + + expect(events).to include( + event_name: :chat_channel_status_change, + params: [ + { channel: private_category_channel, old_status: "read_only", new_status: "archived" }, + ], + ) + expect(messages.first.channel).to eq("/chat/channel-status") + expect(messages.first.data).to eq( + { chat_channel_id: private_category_channel.id, status: "archived" }, + ) + expect(private_category_channel.reload.archived?).to eq(true) + + expect( + UserHistory.exists?( + acting_user_id: staff.id, + action: UserHistory.actions[:custom_staff], + custom_type: "chat_channel_status_change", + new_value: :archived, + previous_value: :read_only, + ), + ).to eq(true) + end + end + + describe "#add" do + before { group.add(user1) } + + it "creates a membership for the user and enqueues a job to update the count" do + initial_count = private_category_channel.user_count + + membership = private_category_channel.add(user1) + private_category_channel.reload + + expect(membership.following).to eq(true) + expect(membership.user).to eq(user1) + expect(membership.chat_channel).to eq(private_category_channel) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "updates an existing membership for the user and enqueues a job to update the count" do + membership = + UserChatChannelMembership.create!( + chat_channel: private_category_channel, + user: user1, + following: false, + ) + + private_category_channel.add(user1) + private_category_channel.reload + + expect(membership.reload.following).to eq(true) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "does nothing if the user is already a member" do + membership = + UserChatChannelMembership.create!( + chat_channel: private_category_channel, + user: user1, + following: true, + ) + + expect(private_category_channel.user_count_stale).to eq(false) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.add(user1) } + end + + it "does not recalculate user count if it's already been marked as stale" do + private_category_channel.update!(user_count_stale: true) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.add(user1) } + end + end + + describe "#remove" do + before do + group.add(user1) + @membership = private_category_channel.add(user1) + private_category_channel.reload + private_category_channel.update!(user_count_stale: false) + end + + it "updates the membership for the user and decreases the count" do + membership = private_category_channel.remove(user1) + private_category_channel.reload + + expect(@membership.reload.following).to eq(false) + expect(private_category_channel.user_count_stale).to eq(true) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "returns nil if the user doesn't have a membership" do + expect(private_category_channel.remove(user2)).to eq(nil) + end + + it "does nothing if the user is not following the channel" do + @membership.update!(following: false) + + private_category_channel.remove(user1) + private_category_channel.reload + + expect(private_category_channel.user_count_stale).to eq(false) + expect_job_enqueued( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) + end + + it "does not recalculate user count if it's already been marked as stale" do + private_category_channel.update!(user_count_stale: true) + expect_not_enqueued_with( + job: :update_channel_user_count, + args: { + chat_channel_id: private_category_channel.id, + }, + ) { private_category_channel.remove(user1) } + end + end +end diff --git a/plugins/chat/spec/support/examples/chatable_model.rb b/plugins/chat/spec/support/examples/chatable_model.rb new file mode 100644 index 00000000000..78237d7937d --- /dev/null +++ b/plugins/chat/spec/support/examples/chatable_model.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a chatable model" do + describe "#chat_channel" do + subject(:chat_channel) { chatable.chat_channel } + + it "returns a new chat channel model" do + expect(chat_channel).to have_attributes persisted?: false, + class: channel_class, + chatable: chatable + end + end + + describe "#create_chat_channel!" do + subject(:create_chat_channel) { chatable.create_chat_channel!(name: name) } + + let(:name) { "a custom name" } + + it "creates a proper chat channel" do + expect { create_chat_channel }.to change { channel_class.count }.by(1) + expect(channel_class.last).to have_attributes chatable: chatable, name: name + end + end +end diff --git a/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb b/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb new file mode 100644 index 00000000000..9a2531120de --- /dev/null +++ b/plugins/chat/spec/validators/chat_allow_uploads_validator_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe ChatAllowUploadsValidator do + it "always returns true if setting the value to false" do + validator = described_class.new + expect(validator.valid_value?("f")).to eq(true) + end + + context "when secure media is enabled" do + before do + SiteSetting.chat_allow_uploads = false + enable_secure_uploads + end + + it "does not allow chat uploads to be enabled" do + validator = described_class.new + expect(validator.valid_value?("t")).to eq(false) + expect(validator.error_message).to eq( + I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads"), + ) + end + + it "allows chat uploads to be enabled if allow_unsecure_chat_uploads global setting is enabled" do + global_setting :allow_unsecure_chat_uploads, true + validator = described_class.new + expect(validator.valid_value?("t")).to eq(true) + expect(validator.error_message).to eq(nil) + end + end +end diff --git a/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb b/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb new file mode 100644 index 00000000000..762c3fce78c --- /dev/null +++ b/plugins/chat/spec/validators/chat_default_channel_validator_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatDefaultChannelValidator do + fab!(:channel) { Fabricate(:category_channel) } + + it "provides an error message" do + validator = described_class.new + expect(validator.error_message).to eq(I18n.t("site_settings.errors.chat_default_channel")) + end + + it "returns true if public channel id" do + validator = described_class.new + expect(validator.valid_value?(channel.id)).to eq(true) + end + + it "returns true if empty string" do + validator = described_class.new + expect(validator.valid_value?("")).to eq(true) + end + + it "returns false if not a public channel" do + validator = described_class.new + channel.destroy! + expect(validator.valid_value?(channel.id)).to eq(false) + end +end diff --git a/plugins/chat/test/javascripts/acceptance/chat-browse-test.js b/plugins/chat/test/javascripts/acceptance/chat-browse-test.js new file mode 100644 index 00000000000..6f8916a2de6 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-browse-test.js @@ -0,0 +1,111 @@ +import { + acceptance, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import I18n from "I18n"; +import fabricators from "../helpers/fabricators"; +import { isEmpty } from "@ember/utils"; + +acceptance("Discourse Chat - browse channels", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + // we don't need anything in the sidebar for this test + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + + server.get("/chat/api/chat_channels.json", (request) => { + const params = request.queryParams; + + if (!isEmpty(params.filter)) { + if (params.filter === "foo") { + return helper.response([fabricators.chatChannel()]); + } else { + return helper.response([]); + } + } + + const channels = []; + if (isEmpty(params.status) || params.status === "open") { + channels.push(fabricators.chatChannel()); + channels.push(fabricators.chatChannel()); + } + + if (params.status === "closed" || isEmpty(params.status)) { + channels.push(fabricators.chatChannel({ status: "closed" })); + } + + if (params.status === "archived" || isEmpty(params.status)) { + channels.push(fabricators.chatChannel({ status: "archived" })); + } + + return helper.response(channels); + }); + }); + + test("Defaults to open filter", async function (assert) { + await visit("/chat/browse"); + assert.equal(currentURL(), "/chat/browse/open"); + }); + + test("All filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-all"); + + assert.equal(currentURL(), "/chat/browse/all"); + assert.equal(queryAll(".chat-channel-card").length, 4); + }); + + test("Open filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-open"); + + assert.equal(currentURL(), "/chat/browse/open"); + assert.equal(queryAll(".chat-channel-card").length, 2); + }); + + test("Closed filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-closed"); + + assert.equal(currentURL(), "/chat/browse/closed"); + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("Archived filter", async function (assert) { + await visit("/chat/browse"); + await click(".chat-browse-view__filter-link.-archived"); + + assert.equal(currentURL(), "/chat/browse/archived"); + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("Filtering results", async function (assert) { + await visit("/chat/browse"); + + assert.equal(queryAll(".chat-channel-card").length, 2); + + await fillIn(".dc-filter-input", "foo"); + + assert.equal(queryAll(".chat-channel-card").length, 1); + }); + + test("No results", async function (assert) { + await visit("/chat/browse"); + await fillIn(".dc-filter-input", "bar"); + + assert.equal( + query(".empty-state-title").innerText.trim(), + I18n.t("chat.empty_state.title") + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js b/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js new file mode 100644 index 00000000000..98c93801295 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channel-info-test.js @@ -0,0 +1,64 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; +import { getOwner } from "discourse-common/lib/get-owner"; +import fabricators from "../helpers/fabricators"; + +acceptance("Discourse Chat - chat channel info", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + const channel = fabricators.chatChannel(); + server.get("/chat/chat_channels.json", () => { + return helper.response({ + publicMessageChannels: [channel], + directMessageChannels: [], + }); + }); + server.get("/chat/chat_channels/:id.json", () => { + return helper.response(channel); + }); + server.get("/chat/api/chat_channels.json", () => + helper.response([channel]) + ); + server.get("/chat/api/chat_channels/:id/memberships.json", () => + helper.response([]) + ); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + }); + + needs.hooks.beforeEach(function () { + this.manager = getOwner(this).lookup( + "service:chat-channel-info-route-origin-manager" + ); + }); + + needs.hooks.afterEach(function () { + this.manager.origin = null; + }); + + test("Direct visit sets origin as channel", async function (assert) { + await visit("/chat/channel/1/my-category-title/info"); + + assert.strictEqual(this.manager.origin, ORIGINS.channel); + }); + + test("Visit from browse sets origin as browse", async function (assert) { + await visit("/chat/browse/open"); + await click(".chat-channel-card__setting"); + + assert.strictEqual(this.manager.origin, ORIGINS.browse); + }); + + test("Visit from channel sets origin as channel", async function (assert) { + await visit("/chat/channel/1/my-category-title"); + await visit("/chat/channel/1/my-category-title/info"); + + assert.strictEqual(this.manager.origin, ORIGINS.channel); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js b/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js new file mode 100644 index 00000000000..a171b0e9036 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channel-slug-test.js @@ -0,0 +1,24 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { currentURL, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { chatChannels } from "discourse/plugins/chat/chat-fixtures"; + +acceptance("Discourse Chat - chat channel slug", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + }); + + test("Replacing title param", async function (assert) { + await visit("/chat"); + await visit("/chat/channel/11/-"); + + assert.equal(currentURL(), "/chat/channel/11/another-category"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js b/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js new file mode 100644 index 00000000000..ec7ed3a1b3b --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-channels-list-test.js @@ -0,0 +1,54 @@ +import { + acceptance, + exists, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; + +acceptance( + "Discourse Chat - Chat Channels list - no joinable public channels", + function (needs) { + needs.user({ has_chat_enabled: true, has_joinable_public_channels: false }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: cloneJSON(directMessageChannels).mapBy( + "chat_channel" + ), + }); + }); + + server.get("/chat/:id/messages.json", () => { + return helper.response({ + chat_messages: [], + meta: { can_chat: true }, + }); + }); + }); + + test("Public chat channels section visibility", async function (assert) { + await visit("/chat"); + + assert.ok( + exists(".public-channels-section"), + "it shows the section for staff" + ); + + updateCurrentUser({ admin: false, moderator: false }); + + assert.notOk( + exists(".public-channels-section"), + "it doesn’t show the section for regular user" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js new file mode 100644 index 00000000000..7963f3a2b44 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js @@ -0,0 +1,181 @@ +import { + acceptance, + exists, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + fillIn, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { test } from "qunit"; +import { + baseChatPretenders, + chatChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance("Discourse Chat - Composer", function (needs) { + needs.user({ id: 1, has_chat_enabled: true }); + needs.settings({ chat_enabled: true, enable_rich_text_paste: true }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + server.post("/chat/drafts", () => { + return helper.response([]); + }); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("when pasting html in composer", async function (assert) { + await visit("/chat/channel/11/another-category"); + + const clipboardEvent = new Event("paste", { bubbles: true }); + clipboardEvent.clipboardData = { + types: ["text/html"], + getData: (type) => { + if (type === "text/html") { + return "Foo"; + } + }, + }; + + document + .querySelector(".chat-composer-input") + .dispatchEvent(clipboardEvent); + + await settled(); + + assert.equal(document.querySelector(".chat-composer-input").value, "Foo"); + }); + + test("when selecting an emoji from the picker", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual(emojiReactionStore.favorites, []); + + await visit("/chat/channel/11/-"); + await click(".chat-composer-dropdown__trigger-btn"); + await click(".chat-composer-dropdown__action-btn.emoji"); + await click(`[data-emoji="grinning"]`); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"], + "it tracks the emoji" + ); + }); + + test("when selecting an emoji from the autocomplete", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual(emojiReactionStore.favorites, []); + + await visit("/chat/channel/11/-"); + await fillIn(".chat-composer-input", "test :grinni"); + await triggerKeyEvent(".chat-composer-input", "keyup", "ArrowDown"); // necessary to show the menu + await click(".autocomplete.ac-emoji ul li:first-child a"); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"], + "it tracks the emoji" + ); + }); +}); + +let sendAttempt = 0; +acceptance("Discourse Chat - Composer - unreliable network", function (needs) { + needs.user({ id: 1, has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => { + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.post("/chat/drafts", () => helper.response(500, {})); + server.post("/chat/:id.json", () => { + sendAttempt += 1; + return sendAttempt === 1 + ? helper.response(500, {}) + : helper.response({ success: true }); + }); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + needs.hooks.afterEach(function () { + sendAttempt = 0; + }); + + test("Sending a message with unreliable network", async function (assert) { + this.chatService.set("chatWindowFullPage", false); + await visit("/chat/channel/11/-"); + await fillIn(".chat-composer-input", "network-error-message"); + await click(".send-btn"); + + assert.ok( + exists(".chat-message-container[data-id='1'] .retry-staged-message-btn"), + "it adds a retry button" + ); + + await fillIn(".chat-composer-input", "network-error-message"); + await click(".send-btn"); + await publishToMessageBus(`/chat/11`, { + type: "sent", + stagedId: 1, + chat_message: { + cooked: "network-error-message", + id: 175, + user: { id: 1 }, + }, + }); + + assert.notOk( + exists(".chat-message-container[data-id='1'] .retry-staged-message-btn"), + "it removes the staged message" + ); + assert.ok( + exists(".chat-message-container[data-id='175']"), + "it sends the message" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "it clears the input" + ); + }); + + test("Draft with unreliable network", async function (assert) { + this.chatService.set("chatWindowFullPage", false); + await visit("/chat/channel/11/-"); + this.chatService.set("isNetworkUnreliable", true); + await settled(); + + assert.ok( + exists(".chat-composer__unreliable-network"), + "it displays a network error icon" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js b/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js new file mode 100644 index 00000000000..ab091287b6c --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-flagging-test.js @@ -0,0 +1,94 @@ +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; +import { click, triggerEvent, visit } from "@ember/test-helpers"; + +acceptance("Discourse Chat - Flagging test", function (needs) { + let defaultChatView; + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 100, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/9/messages.json", () => { + return helper.response( + generateChatView(loggedInUser(), { + can_flag: false, + }) + ); + }); + server.get("/chat/75/messages.json", () => { + defaultChatView = generateChatView(loggedInUser()); + return helper.response(defaultChatView); + }); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.put("/chat/flag", () => { + return helper.response({ success: true }); + }); + }); + needs.settings({ + chat_enabled: true, + }); + + test("Flagging in public channel works", async function (assert) { + await visit("/chat/channel/75/site"); + + assert.notOk(exists(".chat-live-pane .chat-message .chat-message-flagged")); + await triggerEvent(".chat-message-container", "mouseenter"); + + let moreButtons = selectKit(".chat-msgactions-hover .more-buttons"); + await moreButtons.expand(); + + const content = moreButtons.displayedContent(); + assert.ok(content.find((row) => row.id === "flag")); + + await moreButtons.selectRowByValue("flag"); + + await click(".controls.spam input"); + await click(".modal-footer button"); + + await publishToMessageBus("/chat/75", { + type: "self_flagged", + chat_message_id: defaultChatView.chat_messages[0].id, + user_flag_status: 0, + }); + await publishToMessageBus("/chat/75", { + type: "flag", + chat_message_id: defaultChatView.chat_messages[0].id, + reviewable_id: 1, + }); + + const reviewableLink = query( + `.chat-message-container[data-id='${defaultChatView.chat_messages[0].id}'] .chat-message-info__flag a` + ); + assert.ok(reviewableLink.href.endsWith("/review/1")); + }); + + test("Flag button isn't present for DM channel", async function (assert) { + await visit("/chat/channel/9/@hawk"); + await triggerEvent(".chat-message-container", "mouseenter"); + + let moreButtons = selectKit(".chat-msgactions .more-buttons"); + await moreButtons.expand(); + + const content = moreButtons.displayedContent(); + assert.notOk(content.find((row) => row.id === "flag")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js b/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js new file mode 100644 index 00000000000..ec8e0389728 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-keyboard-shortcuts-test.js @@ -0,0 +1,319 @@ +import showModal from "discourse/lib/show-modal"; +import { + acceptance, + exists, + loggedInUser, + query, + queryAll, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + currentURL, + fillIn, + focus, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { KEY_MODIFIER } from "discourse/plugins/chat/discourse/initializers/chat-keyboard-shortcuts"; +import { test } from "qunit"; + +const MODIFIER_OPTIONS = + KEY_MODIFIER === "meta" ? { metaKey: true } : { ctrlKey: true }; + +acceptance("Discourse Chat - Keyboard shortcuts", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.pretender((server, helper) => { + // allows to create a staged message + server.post("/chat/:id.json", () => + helper.response({ + errors: [""], + }) + ); + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.get("/chat/chat_channels/search", () => { + return helper.response({ + public_channels: [ChatChannel.create({ id: 3, title: "seventeen" })], + direct_message_channels: [ + ChatChannel.create({ + id: 4, + users: [{ id: 10, username: "someone" }], + }), + ], + users: [ + { id: 11, username: "smoothies" }, + { id: 12, username: "server" }, + ], + }); + }); + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("channel selector opens channel in float", async function (assert) { + await visit("/latest"); + + await showModal("chat-channel-selector-modal"); + await settled(); + assert.ok(exists("#chat-channel-selector-modal-inner")); + + // All channels should show because the input is blank + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 9 + ); + + // Freaking keyup event isn't triggered by fillIn... + // Next line manually keyup's "r" to make the keyup event run. + // fillIn is needed for `this.filter` but triggerKeyEvent is needed to fire the JS event. + await fillIn("#chat-channel-selector-input", "s"); + await triggerKeyEvent("#chat-channel-selector-input", "keyup", "R"); + + // Only 4 channels match this filter now! + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 4 + ); + + await triggerKeyEvent(document.body, "keyup", "Enter"); + assert.ok(exists(".topic-chat-container.visible")); + assert.notOk(exists("#chat-channel-selector-modal-inner")); + assert.equal(currentURL(), "/latest"); + }); + + test("the current chat channel does not show in the channel selector list", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await showModal("chat-channel-selector-modal"); + await settled(); + + // All channels minus 1 + assert.equal( + queryAll("#chat-channel-selector-modal-inner .chat-channel-selection-row") + .length, + 8 + ); + assert.notOk( + exists( + "#chat-channel-selector-modal-inner .chat-channel-selection-row.chat-channel-9" + ) + ); + }); + + test("switching channel with alt+arrow keys in full page chat", async function (assert) { + this.container.lookup("service:chat").set("chatWindowFullPage", true); + await visit("/chat/channel/75/@hawk"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/76/eviltrout-markvanlan"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/11/another-category"); + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/7/bug"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/11/another-category"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/76/eviltrout-markvanlan"); + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.equal(currentURL(), "/chat/channel/75/hawk"); + }); + + test("switching channel with alt+arrow keys in float", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + + await click(".header-dropdown-toggle.open-chat"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-4")); + + await triggerKeyEvent(document.body, "keydown", "ArrowDown", { + altKey: true, + }); + + assert.ok(query(".topic-chat-container").classList.contains("channel-10")); + + await triggerKeyEvent(document.body, "keydown", "ArrowUp", { + altKey: true, + }); + assert.ok(query(".topic-chat-container").classList.contains("channel-4")); + }); + + test("simple composer formatting shortcuts", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "B", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "**test text**", + "selection should get the bold markdown" + ); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "I", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "_test text_", + "selection should get the italic markdown" + ); + await fillIn(composerInput, "test text"); + await focus(composerInput); + composerInput.selectionStart = 0; + composerInput.selectionEnd = 9; + await triggerKeyEvent(composerInput, "keydown", "E", MODIFIER_OPTIONS); + + assert.strictEqual( + composerInput.value, + "`test text`", + "selection should get the code markdown" + ); + }); + + test("editing last non staged message", async function (assert) { + const stagedMessageText = "This is a test"; + await visit("/latest"); + + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + await fillIn(".chat-composer-input", stagedMessageText); + await click(".chat-composer-inline-button"); + await triggerKeyEvent(".chat-composer-input", "keydown", "ArrowUp"); + + assert.notEqual( + query(".chat-composer-input").value.trim(), + stagedMessageText + ); + }); + + test("insert link shortcut", async function (assert) { + await visit("/latest"); + + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + + await focus(".chat-composer-input"); + await fillIn(".chat-composer-input", "This is a link to "); + await triggerKeyEvent( + ".chat-composer-input", + "keydown", + "L", + MODIFIER_OPTIONS + ); + assert.ok(exists(".insert-link.modal-body"), "hyperlink modal visible"); + + await fillIn(".modal-body .link-url", "google.com"); + await fillIn(".modal-body .link-text", "Google"); + await click(".modal-footer button.btn-primary"); + + assert.strictEqual( + query(".chat-composer-input").value, + "This is a link to [Google](https://google.com)", + "adds link with url and text, prepends 'https://'" + ); + + assert.ok( + !exists(".insert-link.modal-body"), + "modal dismissed after submitting link" + ); + }); + + test("Dash key (-) opens chat float", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + + await triggerKeyEvent(document.body, "keydown", "-"); + assert.ok(exists(".topic-chat-drawer-content"), "chat float is open"); + }); + + test("Pressing Escape when drawer is opened", async function (assert) { + await visit("/latest"); + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + await click(".header-dropdown-toggle.open-chat"); + + const composerInput = query(".chat-composer-input"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Escape"); + + assert.ok( + exists(".topic-chat-float-container.hidden"), + "it closes the drawer" + ); + }); + + test("Pressing Escape when full page is opened", async function (assert) { + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", true); + await visit("/chat/channel/75/@hawk"); + const composerInput = query(".chat-composer-input"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Escape"); + + assert.equal( + currentURL(), + "/chat/channel/75/hawk", + "it doesn’t close full page chat" + ); + + assert.ok( + exists(".chat-message-container[data-id='177']"), + "it doesn’t remove channel content" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js new file mode 100644 index 00000000000..ad95b49b574 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js @@ -0,0 +1,156 @@ +import { click, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane collapse", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_chat: true, + user_silenced: false, + }, + chat_messages: [ + { + id: 1, + message: "https://www.youtube.com/watch?v=aOWkVdU4NH0", + cooked: + '', + excerpt: + '[Picnic with my cat (shaved ice & lemonade…', + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + { + id: 2, + message: "", + cooked: "", + excerpt: "", + uploads: [ + { + id: 4, + url: "/images/avatar.png", + original_filename: "tomtom.jpeg", + filesize: 93815, + width: 480, + height: 640, + thumbnail_width: 375, + thumbnail_height: 500, + extension: "jpeg", + retain_hours: null, + human_filesize: "91.6 KB", + }, + ], + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/uploads/lookup-urls", () => + helper.response([ + 200, + { "Content-Type": "application/json" }, + [ + { + url: "/images/avatar.png", + }, + ], + ]) + ); + }); + + test("can collapse and expand youtube chat", async function (assert) { + const youtubeContainer = ".chat-message-container[data-id='1'] .lazyYT"; + const expandImage = + ".chat-message-container[data-id='1'] .chat-message-collapser-closed"; + const collapseImage = + ".chat-message-container[data-id='1'] .chat-message-collapser-opened"; + + await visit("/chat/channel/1/cat"); + + assert.ok(visible(youtubeContainer)); + assert.ok(visible(collapseImage), "the open arrow is shown"); + assert.notOk(exists(expandImage), "the close arrow is hidden"); + + await click(collapseImage); + + assert.notOk(visible(youtubeContainer)); + assert.ok(visible(expandImage), "the close arrow is shown"); + assert.notOk(exists(collapseImage), "the open arrow is hidden"); + + await click(expandImage); + + assert.ok(visible(youtubeContainer)); + assert.ok(visible(collapseImage), "the open arrow is shown again"); + assert.notOk(exists(expandImage), "the close arrow is hidden again"); + }); + + test("lightbox shows up before and after expand and collapse", async function (assert) { + const lightboxImage = ".mfp-img"; + const image = ".chat-message-container[data-id='2'] .chat-img-upload"; + const expandImage = + ".chat-message-container[data-id='2'] .chat-message-collapser-closed"; + const collapseImage = + ".chat-message-container[data-id='2'] .chat-message-collapser-opened"; + + await visit("/chat/channel/1/cat"); + + await click(image); + + assert.ok( + exists(document.querySelector(lightboxImage)), + "can see lightbox" + ); + await click(document.querySelector(".mfp-container")); + + await click(collapseImage); + await click(expandImage); + + await click(image); + assert.ok( + exists(document.querySelector(lightboxImage)), + "can see lightbox after collapse expand" + ); + await click(document.querySelector(".mfp-container")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js new file mode 100644 index 00000000000..a7fb328d458 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-mobile-test.js @@ -0,0 +1,86 @@ +import { click, visit } from "@ember/test-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane mobile", function (needs) { + needs.mobileView(); + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_flag: true, + user_silenced: true, + }, + chat_messages: [ + { + id: 1, + message: "hi", + cooked: "

hi

", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + { + id: 2, + message: "hi", + cooked: "

hi

", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + }); + + test("touching message", async function (assert) { + await visit("/chat/channel/1/cat"); + + const messageExists = (id) => { + return exists( + `.chat-message-container[data-id='${id}'] .chat-message-selected` + ); + }; + + assert.notOk(messageExists(1)); + assert.notOk(messageExists(2)); + + await click(".chat-message-container[data-id='1']"); + + assert.notOk(messageExists(1), "it doesn’t select the touched message"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js new file mode 100644 index 00000000000..777124eb3fb --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-silenced-user-test.js @@ -0,0 +1,81 @@ +import { visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Chat live pane", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { + can_flag: true, + user_silenced: true, + }, + chat_messages: [ + { + id: 1, + message: "hi", + cooked: "

hi

", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: + "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + reactions: { + heart: { + count: 1, + reacted: false, + users: [{ id: 99, username: "im-penar" }], + }, + }, + }, + ], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ + id: 1, + title: "something", + current_user_membership: { following: true }, + }) + ); + }); + + test("Textarea and message interactions are disabled when user is silenced", async function (assert) { + await visit("/chat/channel/1/cat"); + assert.equal(query(".chat-composer-input").disabled, true); + assert.notOk(exists(".chat-msgactions-hover")); + assert.notOk(exists(".chat-message-react-btn")); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js new file mode 100644 index 00000000000..1ac586ff650 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-test.js @@ -0,0 +1,304 @@ +import { click, fillIn, tap, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { generateChatView } from "discourse/plugins/chat/chat-fixtures"; + +function buildMessage(messageId) { + return { + id: messageId, + message: "hi", + cooked: "

hi

", + excerpt: "hi", + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + avatar_template: "/letter_avatar_proxy/v4/letter/t/a9a28c/{size}.png", + id: 1, + name: "Tomtom", + username: "tomtom", + }, + }; +} + +acceptance( + "Discourse Chat - Chat live pane - viewing old messages", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + + let loadAllMessages = false; + + needs.hooks.beforeEach(() => { + loadAllMessages = false; + }); + + needs.pretender((server, helper) => { + const firstPageMessages = []; + + for (let i = 0; i < 50; i++) { + firstPageMessages.push(buildMessage(i + 1)); + } + + server.get("/chat/:chatChannelId/messages.json", () => { + if (loadAllMessages) { + const updatedPage = [...firstPageMessages]; + updatedPage.shift(); + updatedPage.shift(); + updatedPage.push(buildMessage(51)); + updatedPage.push(buildMessage(52)); + + return helper.response({ + meta: { + can_load_more_future: false, + }, + chat_messages: updatedPage, + }); + } else { + return helper.response({ + meta: { + can_flag: true, + user_silenced: false, + can_load_more_future: true, + }, + chat_messages: firstPageMessages, + }); + } + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + test("doesn't create a gap in history by adding new messages", async function (assert) { + await visit("/chat/channel/1/cat"); + + await publishToMessageBus("/chat/1", { + type: "sent", + chat_message: { + id: 51, + cooked: "

hello!

", + user: { + id: 2, + }, + }, + }); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + }); + + test("It continues to handle other message types", async function (assert) { + await visit("/chat/channel/1/cat"); + + await publishToMessageBus("/chat/1", { + action: "add", + user: { id: 77, username: "notTomtom" }, + emoji: "cat", + type: "reaction", + chat_message_id: 1, + }); + + assert.ok(exists(`.chat-message-reaction[data-emoji-name="cat"]`)); + }); + + test("Sending a new message when there are still unloaded ones will fetch them", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + + loadAllMessages = true; + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "test text"); + await click(".send-btn"); + + assert.ok(exists(`.chat-message-container[data-id='${51}']`)); + assert.ok(exists(`.chat-message-container[data-id='${52}']`)); + }); + + test("Clicking the arrow button jumps to the bottom of the channel", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.notOk(exists(`.chat-message-container[data-id='${51}']`)); + const scrollerEl = document.querySelector(".chat-messages-scroll"); + scrollerEl.scrollTop = -500; // Scroll up a bit + const initialPosition = scrollerEl.scrollTop; + await triggerEvent(".chat-messages-scroll", "scroll", { + forceShowScrollToBottom: true, + }); + + loadAllMessages = true; + await click(".chat-scroll-to-bottom"); + + assert.ok(exists(`.chat-message-container[data-id='${51}']`)); + assert.ok(exists(`.chat-message-container[data-id='${52}']`)); + + assert.ok( + scrollerEl.scrollTop > initialPosition, + "Scrolled to the bottom" + ); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane - handling 429 errors", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => { + return helper.response(429); + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [ + { + id: 1, + title: "something", + current_user_membership: { following: true }, + }, + ], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.post("/chat/drafts", () => { + return helper.response([]); + }); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + test("Handles 429 errors by displaying an alert", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.ok(exists(".dialog-content"), "We displayed a 429 error"); + await click(".dialog-footer .btn-primary"); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane - handling 404 errors", + function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + has_chat_enabled: true, + }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => { + return helper.response(404); + }); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + }); + + test("Handles 404 errors by displaying an alert", async function (assert) { + await visit("/chat/channel/1/cat"); + + assert.ok(exists(".dialog-content"), "it displays a 404 error"); + await click(".dialog-footer .btn-primary"); + }); + } +); + +acceptance( + "Discourse Chat - Chat live pane (mobile) - actions menu", + function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ chat_enabled: true }); + + needs.mobileView(); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + }); + + test("when expanding and collapsing the actions menu", async function (assert) { + await visit("/chat/channel/1/cat"); + const message = query(".chat-message-container"); + await tap(message); + + assert.ok(exists(".chat-msgactions-backdrop")); + + await tap(".collapse-area"); + + assert.notOk(exists(".chat-msgactions-backdrop")); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js b/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js new file mode 100644 index 00000000000..2f7b22d5fe8 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-message-bookmarking-test.js @@ -0,0 +1,165 @@ +import { test } from "qunit"; +import { click, fillIn, tap, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); +} + +acceptance("Discourse Chat | bookmarking | desktop", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.post("/bookmarks", () => helper.response({ id: 1, success: "OK" })); + }); + + test("can bookmark a message with reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await triggerEvent(message, "mouseenter"); + await click(".chat-msgactions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_next_month"); + assert.ok( + message.querySelector( + ".chat-message-info__bookmark .d-icon-discourse-bookmark-clock" + ), + "the message should be bookmarked and show the icon on the message info" + ); + assert.ok( + ".chat-msgactions .bookmark-btn .d-icon-discourse-bookmark-clock", + "the message actions icon shows the reminder icon" + ); + }); + + test("can bookmark a message without reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await triggerEvent(message, "mouseenter"); + await click(".chat-msgactions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_none"); + assert.ok( + exists(".chat-message-info__bookmark .d-icon-bookmark"), + "the message should be bookmarked and show the icon on the message info" + ); + assert.ok( + exists(".chat-msgactions .bookmark-btn .d-icon-bookmark"), + "the message actions icon shows the bookmark icon" + ); + }); +}); + +acceptance("Discourse Chat | bookmarking | mobile", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.post("/bookmarks", () => helper.response({ id: 1, success: "OK" })); + }); + + needs.mobileView(); + + test("can bookmark a message with reminder from the mobile long press menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await tap(message); + await click(".main-actions .bookmark-btn"); + + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_next_month"); + assert.ok( + message.querySelector( + ".chat-message-info__bookmark .d-icon-discourse-bookmark-clock" + ), + "the message should be bookmarked and show the icon on the message info" + ); + + await tap(message); + assert.ok( + exists(".main-actions .bookmark-btn .d-icon-discourse-bookmark-clock"), + "the message actions icon shows the reminder icon" + ); + }); + + test("can bookmark a message without reminder from the quick actions menu", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const message = query(".chat-message-container"); + + await tap(message); + await click(".main-actions .bookmark-btn"); + assert.ok( + exists("#bookmark-reminder-modal"), + "it shows the bookmark modal" + ); + await fillIn("input#bookmark-name", "Check this out later"); + await click("#tap_tile_none"); + assert.ok( + message.querySelector(".chat-message-info__bookmark .d-icon-bookmark"), + "the message should be bookmarked and show the icon on the message info" + ); + + await tap(message); + assert.ok( + exists(".main-actions .bookmark-btn .d-icon-bookmark"), + "the message actions icon shows the bookmark icon" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-message-test.js b/plugins/chat/test/javascripts/acceptance/chat-message-test.js new file mode 100644 index 00000000000..8bb00f7ebf4 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-message-test.js @@ -0,0 +1,85 @@ +import { + acceptance, + loggedInUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, triggerEvent, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + server.put("/chat/:id/react/:message_id.json", helper.response); +} + +acceptance("Discourse Chat - Chat Message", function (needs) { + needs.user({ has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => setupPretenders(server, helper)); + + test("when reacting to a message using inline reaction", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual(emojiReactionStore.favorites, []); + + await visit("/chat/channel/4/public-category"); + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="heart"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["heart"], + "it tracks the emoji" + ); + + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="heart"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["heart"], + "it doesn’t untrack when removing the reaction" + ); + }); + + test("when reacting to a message using emoji picker reaction", async function (assert) { + const emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + + assert.deepEqual(emojiReactionStore.favorites, []); + + await visit("/chat/channel/4/public-category"); + await triggerEvent(".chat-message-container[data-id='176']", "mouseenter"); + await click(".chat-msgactions-hover .react-btn"); + await click(`[data-emoji="grinning"]`); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"], + "it tracks the emoji" + ); + + await click( + `.chat-message-container[data-id="176"] .chat-message-reaction[data-emoji-name="grinning"]` + ); + + assert.deepEqual( + emojiReactionStore.favorites, + ["grinning"], + "it doesn’t untrack when removing the reaction" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js b/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js new file mode 100644 index 00000000000..1a745e65fa8 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-move-message-to-channel-test.js @@ -0,0 +1,183 @@ +import { test } from "qunit"; +import { click, currentURL, triggerEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.put("/chat/4/move_messages_to_channel.json", () => { + return helper.response({ + destination_channel_id: 11, + destination_channel_title: "Coolest thing you have seen today", + first_moved_message_id: 174, + }); + }); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(loggedInUser())) + ); +} + +acceptance( + "Discourse Chat | moving messages to a channel | staff user", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + test("opens a modal for destination channel selection then redirects to the moved messages when done", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + const moveToChannelBtn = query( + ".chat-live-pane #chat-move-to-channel-btn" + ); + assert.equal( + moveToChannelBtn.disabled, + false, + "button is enabled as a message is selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + assert.equal( + moveToChannelBtn.disabled, + true, + "button is disabled when no messages are selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + await click("#chat-move-to-channel-btn"); + const modalConfirmMoveButton = query( + "#chat-confirm-move-messages-to-channel" + ); + assert.ok( + modalConfirmMoveButton.disabled, + "cannot confirm move until channel is selected" + ); + const channelChooser = selectKit(".chat-move-message-channel-chooser"); + await channelChooser.expand(); + assert.notOk( + channelChooser.rowByValue("4").exists(), + "the source channel is not in the destination channel selector" + ); + + await channelChooser.selectRowByValue("11"); + await click(modalConfirmMoveButton); + + assert.strictEqual( + currentURL(), + "/chat/channel/11/another-category", + "it goes to the destination channel after the move" + ); + }); + + test("does not allow moving messages from a direct message channel", async function (assert) { + await visit("/chat/channel/75/@hawk"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.notOk( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "the move to channel button is not shown in direct message channels" + ); + }); + } +); + +acceptance( + "Discourse Chat | moving messages to a channel | non-staff user", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + server.get("/chat/11/messages.json", () => { + return helper.response( + generateChatView(loggedInUser(), { can_moderate: true }) + ); + }); + }); + + test("non-staff users cannot see the move to channel button", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.notOk( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "non-staff users cannot see the move to channel button" + ); + }); + + test("non-staff users can see the move to channel button if they can_moderate the channel", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.ok(exists(".chat-message-container")); + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-msgactions-hover[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.ok( + exists(".chat-live-pane #chat-move-to-channel-btn"), + "non-staff users can see the move to channel button if can_moderate" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js b/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js new file mode 100644 index 00000000000..e30af93eab1 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-quoting-test.js @@ -0,0 +1,183 @@ +import { skip, test } from "qunit"; +import { + click, + currentURL, + tap, + triggerEvent, + visit, +} from "@ember/test-helpers"; +import { + acceptance, + exists, + loggedInUser, + query, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +const quoteResponse = { + markdown: `[chat quote="martin-chat;3875498;2022-02-04T01:12:15Z" channel="The Beam Discussions" channelId="1234"] + an extremely insightful response :) + [/chat]`, +}; + +function setupPretenders(server, helper) { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.post(`/chat/4/quote.json`, () => helper.response(quoteResponse)); + server.post(`/chat/7/quote.json`, () => helper.response(quoteResponse)); + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); +} + +acceptance("Discourse Chat | Copying messages", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + test("it copies the quote and shows a message", async function (assert) { + await visit("/chat/channel/7/Bug"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-msgactions-hover[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + const copyButton = query(".chat-live-pane #chat-copy-btn"); + assert.equal( + copyButton.disabled, + false, + "button is enabled as a message is selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + assert.equal( + copyButton.disabled, + true, + "button is disabled when no messages are selected" + ); + + await click(firstMessage.querySelector("input[type='checkbox']")); + await click("#chat-copy-btn"); + assert.ok(exists(".chat-selection-message"), "shows the message"); + }); +}); + +acceptance("Discourse Chat | Quoting in composer", async function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + skip("it opens the composer for the topic and pastes in the quote", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click(".header-dropdown-toggle.open-chat"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit(".chat-message-container .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + await click("#chat-quote-btn"); + assert.ok(exists("#reply-control.composer-action-reply")); + assert.strictEqual( + query(".composer-action-title .action-title").innerText, + "Internationalization / localization" + ); + assert.strictEqual( + query("textarea.d-editor-input").value, + quoteResponse.markdown + ); + }); +}); + +acceptance("Discourse Chat | Quoting on mobile", async function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + setupPretenders(server, helper); + }); + + needs.mobileView(); + + skip("it opens the chatable, opens the composer, and pastes the markdown in", async function (assert) { + await visit("/chat/channel/7/Bug"); + assert.ok(exists(".chat-message-container")); + + const firstMessage = query(".chat-message-container"); + await tap(firstMessage); + await click(".chat-message-action-item[data-id='selectMessage'] button"); + assert.ok(firstMessage.classList.contains("selecting-messages")); + + await click("#chat-quote-btn"); + + assert.equal(currentURL(), "/c/bug/1", "navigates to the chatable url"); + assert.ok( + exists("#reply-control.composer-action-createTopic"), + "the composer opens" + ); + assert.strictEqual( + query("textarea.d-editor-input").value, + quoteResponse.markdown, + "the composer has the markdown" + ); + assert.strictEqual( + selectKit(".category-chooser").header().value(), + "1", + "it fills category selector with the right category" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js b/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js new file mode 100644 index 00000000000..0f54415bcd2 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-sidebar-user-status-test.js @@ -0,0 +1,117 @@ +import { + acceptance, + exists, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; + +acceptance("Discourse Chat - Sidebar - User Status", function (needs) { + const directMessageUserId = 1; + const status = { description: "off to dentist", emoji: "tooth" }; + + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + const directMessageChannel = { + chatable: { + users: [ + { + id: directMessageUserId, + username: "user1", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + status, + }, + ], + }, + chatable_type: "DirectMessageChannel", + title: "@user1", + }; + + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [directMessageChannel], + }); + }); + }); + + test("Shows user status", async function (assert) { + await visit("/"); + + const statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "status is shown"); + assert.ok( + statusEmoji.src.includes(status.emoji), + "status emoji is correct" + ); + assert.equal( + statusEmoji.title, + status.description, + "status description is correct" + ); + }); + + test("Status gets updated after receiving a message bus update", async function (assert) { + await visit("/"); + + let statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "old status is shown"); + assert.ok( + statusEmoji.src.includes(status.emoji), + "old status emoji is correct" + ); + assert.equal( + statusEmoji.title, + status.description, + "old status description is correct" + ); + + const newStatus = { description: "surfing", emoji: "surfer" }; + await publishToMessageBus(`/user-status`, { + [directMessageUserId]: newStatus, + }); + + statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "new status is shown"); + assert.ok( + statusEmoji.src.includes(newStatus.emoji), + "new status emoji is correct" + ); + assert.equal( + statusEmoji.title, + newStatus.description, + "new status description is correct" + ); + }); + + test("Status disappears after receiving a message bus update", async function (assert) { + await visit("/"); + + let statusEmoji = query( + ".sidebar-sections .sidebar-section-link-content-text .emoji" + ); + assert.ok(statusEmoji, "old status is shown"); + + await publishToMessageBus(`/user-status`, { [directMessageUserId]: null }); + + assert.notOk( + exists(".sidebar-sections .sidebar-section-link-content-text .emoji"), + "status has disappeared" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/chat-status-test.js b/plugins/chat/test/javascripts/acceptance/chat-status-test.js new file mode 100644 index 00000000000..3b3e13274f3 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-status-test.js @@ -0,0 +1,106 @@ +import { visit } from "@ember/test-helpers"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { test } from "qunit"; + +const baseChatPretenders = (server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.get("/chat/chat_channels.json", () => { + let copy = cloneJSON(chatChannels); + let modifiedChannel = copy.public_channels.find((pc) => pc.id === 4); + modifiedChannel.current_user_membership.unread_count = 2; + return helper.response(copy); + }); + + // this is only fetched on channel-status change; when expanding on + // this test we may want to introduce some counter to track when + // this is fetched if we want to return different statuses + server.get("/chat/chat_channels/4", () => { + let channel = cloneJSON( + chatChannels.public_channels.find((pc) => pc.id === 4) + ); + channel.status = "archived"; + return helper.response(channel); + }); +}; + +acceptance( + "Discourse Chat - Respond to /chat/channel-status archive message", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "tomtom", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + chat_allow_archiving_channels: true, + }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + }); + + test("it clears any unread messages in the sidebar for the archived channel", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.ok( + exists("#chat-channel-row-4 .chat-channel-unread-indicator"), + "unread indicator shows for channel" + ); + + await publishToMessageBus("/chat/channel-status", { + chat_channel_id: 4, + status: "archived", + }); + assert.notOk( + exists("#chat-channel-row-4 .chat-channel-unread-indicator"), + "unread indicator should not show after archive status change" + ); + }); + + test("it changes the channel status in the header to archived", async function (assert) { + await visit("/chat/channel/4/Topic"); + + assert.notOk( + exists(".chat-channel-title-with-status .chat-channel-status"), + "channel status does not show if the channel is open" + ); + + await publishToMessageBus("/chat/channel-status", { + chat_channel_id: 4, + status: "archived", + }); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.archived_header"), + "channel status changes to archived" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-test.js b/plugins/chat/test/javascripts/acceptance/chat-test.js new file mode 100644 index 00000000000..fcc5414d900 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-test.js @@ -0,0 +1,1864 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import { + acceptance, + exists, + loggedInUser, + publishToMessageBus, + query, + queryAll, + updateCurrentUser, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { + click, + currentURL, + fillIn, + focus, + settled, + triggerEvent, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { skip, test } from "qunit"; +import { + chatChannels, + messageContents, +} from "discourse/plugins/chat/chat-fixtures"; +import Session from "discourse/models/session"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + joinChannel, + leaveChannel, + presentUserIds, +} from "discourse/tests/helpers/presence-pretender"; +import User from "discourse/models/user"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import sinon from "sinon"; +import * as ajaxModule from "discourse/lib/ajax"; +import I18n from "I18n"; +import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import fabricators from "../helpers/fabricators"; +import { + baseChatPretenders, + chatChannelPretender, + directMessageChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance("Discourse Chat - anonymouse 🐭 user", function (needs) { + needs.settings({ + chat_enabled: true, + }); + + test("doesn't error for anonymous users", async function (assert) { + await visit(""); + assert.ok(true, "no errors on homepage"); + }); +}); + +acceptance("Discourse Chat - without unread", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + const hawkAsJson = { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: "/letter_avatar_proxy/v4/letter/t/41988e/{size}.png", + }; + server.get("/u/search/users", () => { + return helper.response({ + users: [hawkAsJson], + }); + }); + server.get("/chat/emojis.json", () => + helper.response({ favorites: [{ name: "grinning" }] }) + ); + + server.put("/chat/:chat_channel_id/react/:messageId.json", helper.response); + + server.put("/chat/:chat_channel_id/invite", helper.response); + server.post("/chat/direct_messages/create.json", () => { + return helper.response({ + chat_channel: { + chat_channels: [], + chatable: { users: [hawkAsJson] }, + chatable_id: 16, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 75, + title: "@hawk", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }, + }); + }); + server.post("/chat/chat_channels/:chatChannelId/unfollow.json", () => { + return helper.response({ current_user_membership: { following: false } }); + }); + server.get("/chat/direct_messages.json", () => { + return helper.response({ + chat_channel: { + id: 75, + title: "hawk", + chatable_type: "DirectMessageChannel", + last_message_sent_at: "2021-07-20T08:14:16.950Z", + chatable: { + users: [{ username: "hawk" }], + }, + }, + }); + }); + server.get("/u/hawk/card.json", () => { + return helper.response({}); + }); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "appEvents", { + get: () => this.container.lookup("service:appEvents"), + }); + Session.current().highlightJsPath = + "/assets/highlightjs/highlight-test-bundle.min.js"; + }); + + // TODO: needs a future change to how we handle URLS to be possible + skip("Clicking mention notification from outside chat opens the float", async function (assert) { + this.chatService.set("chatWindowFullPage", false); + await visit("/t/internationalization-localization/280"); + await click(".header-dropdown-toggle.current-user"); + await click("#quick-access-notifications .chat-mention"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-9")); + }); + + test("notifications for current user and here/all are highlighted", async function (assert) { + updateCurrentUser({ username: "osama" }); + await visit("/chat/channel/11/another-category"); + // 177 is message id from fixture + const highlighted = []; + const notHighlighted = []; + query(".chat-message-container[data-id='177']") + .querySelectorAll(".mention.highlighted") + .forEach((node) => { + highlighted.push(node.textContent.trim()); + }); + query(".chat-message-container[data-id='177']") + .querySelectorAll(".mention:not(.highlighted)") + .forEach((node) => { + notHighlighted.push(node.textContent.trim()); + }); + assert.equal(highlighted.length, 2, "2 mentions are highlighted"); + assert.equal(notHighlighted.length, 1, "1 mention is regular mention"); + assert.ok(highlighted.includes("@here"), "@here mention is highlighted"); + assert.ok(highlighted.includes("@osama"), "@osama mention is highlighted"); + assert.ok( + notHighlighted.includes("@mark"), + "@mark mention is not highlighted" + ); + }); + + test("Chat messages are populated when a channel is entered and images are rendered", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message .chat-message-text"); + assert.equal(messages[0].innerText.trim(), messageContents[0]); + + assert.ok(messages[1].querySelector("a.chat-other-upload")); + + assert.equal( + messages[2].innerText.trim().split("\n")[0], + messageContents[2] + ); + assert.ok(messages[2].querySelector("img.chat-img-upload")); + }); + + test("Reply-to line is hidden when reply-to message is directly above", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + assert.notOk(messages[1].querySelector(".chat-reply__excerpt")); + }); + + test("Reply-to line is present when reply-to message is not directly above", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + const replyTo = messages[2].querySelector(".chat-reply__excerpt"); + assert.ok(replyTo); + assert.equal(replyTo.innerText.trim(), messageContents[0]); + }); + + test("Unfollowing a direct message channel transitions to another channel", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await click( + ".chat-channel-row.chat-channel-75 .toggle-channel-membership-button.-leave" + ); + + assert.ok(/^\/chat\/channel\/4/.test(currentURL())); + }); + + test("Admin only controls are present", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const currentUserDropdown = selectKit( + ".chat-msgactions-hover[data-id='174'] .more-buttons" + ); + await currentUserDropdown.expand(); + + assert.notOk( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it doesn’t show the rebake button for non staff" + ); + + await visit("/"); + updateCurrentUser({ admin: true, moderator: true }); + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await currentUserDropdown.expand(); + + assert.ok( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it shows the rebake button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("silence").exists(), + "it hides the silence button" + ); + + const notCurrentUserDropdown = selectKit( + ".chat-msgactions-hover[data-id='175'] .more-buttons" + ); + await triggerEvent(".chat-message-container[data-id='175']", "mouseenter"); + await notCurrentUserDropdown.expand(); + assert.ok( + notCurrentUserDropdown.rowByValue("silence").exists(), + "it shows the silence button" + ); + }); + + test("Message controls are present and correct for permissions", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + // User created this message + assert.ok( + ".chat-msgactions-hover[data-id='174'] .reply-btn", + "it shows the reply button" + ); + + const currentUserDropdown = selectKit( + ".chat-msgactions-hover[data-id='174'] .more-buttons" + ); + await currentUserDropdown.expand(); + + assert.ok( + currentUserDropdown.rowByValue("copyLinkToMessage").exists(), + "it shows the link to button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("rebakeMessage").exists(), + "it doesn’t show the rebake button to a regular user" + ); + + assert.ok( + currentUserDropdown.rowByValue("edit").exists(), + "it shows the edit button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("flag").exists(), + "it hides the flag button" + ); + + assert.notOk( + currentUserDropdown.rowByValue("silence").exists(), + "it hides the silence button" + ); + + assert.ok( + currentUserDropdown.rowByValue("deleteMessage").exists(), + "it shows the delete button" + ); + + // User _didn't_ create this message + await triggerEvent(".chat-message-container[data-id='175']", "mouseenter"); + assert.ok( + ".chat-msgactions-hover[data-id='175'] .reply-btn", + "it shows the reply button" + ); + const notCurrentUserDropdown = selectKit( + ".chat-msgactions-hover[data-id='175'] .more-buttons" + ); + await notCurrentUserDropdown.expand(); + + assert.ok( + notCurrentUserDropdown.rowByValue("copyLinkToMessage").exists(), + "it shows the link to button" + ); + + assert.notOk( + notCurrentUserDropdown.rowByValue("edit").exists(), + "it hides the edit button" + ); + + assert.notOk( + notCurrentUserDropdown.rowByValue("deleteMessage").exists(), + "it hides the delete button" + ); + }); + + test("pressing the reply button adds the indicator to the composer", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".reply-btn"); + assert.ok( + exists(".chat-composer-message-details .d-icon-reply"), + "Reply icon is present" + ); + assert.equal( + query( + ".chat-composer-message-details .chat-reply__username" + ).innerText.trim(), + "markvanlan" + ); + }); + + test("pressing the edit button fills the composer and indicates edit", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const dropdown = selectKit(".more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("edit"); + + assert.ok( + exists(".chat-composer-message-details .d-icon-pencil-alt"), + "Edit icon is present" + ); + assert.equal( + query( + ".chat-composer-message-details .chat-reply__username" + ).innerText.trim(), + "markvanlan" + ); + + assert.equal( + query(".chat-composer-input").value.trim(), + messageContents[0] + ); + }); + + test("Reply-to is stored in draft", async function (assert) { + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + await visit("/latest"); + this.appEvents.trigger("chat:toggle-open"); + await settled(); + + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-9"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".chat-msgactions-hover[data-id='174'] .reply-btn"); + // Reply-to line is present + assert.ok(exists(".chat-composer-message-details .chat-reply")); + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-11"); + // Reply-to line is gone since switching channels + assert.notOk(exists(".chat-composer-message-details .chat-reply")); + // Now click on reply btn and cancel it on channel 7 + + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + await click(".chat-msgactions-hover[data-id='174'] .reply-btn"); + await click(".cancel-message-action"); + + // Go back to channel 9 and check that reply-to is present + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-9"); + // Now reply-to should be back and loaded from draft + assert.ok(exists(".chat-composer-message-details .chat-reply")); + + // Go back one for time to channel 7 and make sure reply-to is gone + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-11"); + assert.notOk(exists(".chat-composer-message-details .chat-reply")); + }); + + test("Sending a message", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messageContent = "Here's a message"; + const composerInput = query(".chat-composer-input"); + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [], + "is not present before typing" + ); + await fillIn(composerInput, messageContent); + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [User.current().id], + "is present after typing" + ); + await focus(composerInput); + + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + assert.equal(document.activeElement, composerInput); + + assert.equal(composerInput.innerText.trim(), "", "composer input cleared"); + + assert.deepEqual( + presentUserIds("/chat-reply/11"), + [], + "stops being present after sending message" + ); + + let messages = queryAll(".chat-message"); + let lastMessage = messages[messages.length - 1]; + + // Message is staged, without an ID + assert.ok(lastMessage.classList.contains("chat-message-staged")); + + // Last message was from a different user; full meta data is shown + assert.ok( + lastMessage.querySelector(".chat-user-avatar"), + "Avatar is present" + ); + assert.ok( + lastMessage.querySelector(".chat-message-info__username__name"), + "Username is present" + ); + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + this.siteSettings.enable_markdown_typographer + ? "Here’s a message" + : messageContent + ); + + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + user: { + id: 1, + }, + cooked: messageContent + " some extra cooked stuff", + }, + }); + + assert.equal( + lastMessage.closest(".chat-message-container").dataset.id, + 202 + ); + assert.notOk(lastMessage.classList.contains("chat-message-staged")); + + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + messageContent + " some extra cooked stuff", + "last message is updated with the cooked content of the message" + ); + + const nextMessageContent = "What up what up!"; + await fillIn(composerInput, nextMessageContent); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + messages = queryAll(".chat-message"); + lastMessage = messages[messages.length - 1]; + + // We just sent a message so avatar/username will not be present for the last message + assert.notOk( + lastMessage.querySelector(".chat-user-avatar"), + "Avatar is not shown" + ); + assert.notOk( + lastMessage.querySelector(".full-name"), + "Username is not shown" + ); + assert.equal( + lastMessage.querySelector(".chat-message-text").innerText.trim(), + nextMessageContent + ); + }); + + test("cooked processing messages are handled properly", async function (assert) { + await visit("/chat/channel/11/another-category"); + + const cooked = "

hello there

"; + await publishToMessageBus(`/chat/11`, { + type: "processed", + chat_message: { + cooked, + id: 175, + }, + }); + + assert.ok( + query( + ".chat-message-container[data-id='175'] .chat-message-text" + ).innerHTML.includes(cooked) + ); + }); + + test("Code highlighting in a message", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messageContent = `Here's a message with code highlighting + +\`\`\`ruby +Widget.triangulate(arg: "test") +\`\`\``; + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, messageContent); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + cooked: `
Widget.triangulate(arg: "test")
+      
`, + user: { + id: 1, + }, + }, + }); + + const messages = queryAll(".chat-message"); + const lastMessage = messages[messages.length - 1]; + assert.equal( + lastMessage.closest(".chat-message-container").dataset.id, + 202 + ); + assert.ok( + exists( + ".chat-message-container[data-id='202'] .chat-message-text code.lang-ruby.hljs" + ), + "chat message code block has been highlighted as ruby code" + ); + }); + + test("Drafts are saved and reloaded", async function (assert) { + await visit("/chat/channel/11/another-category"); + await fillIn(".chat-composer-input", "Hi people"); + + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), ""); + await fillIn(".chat-composer-input", "What up what up"); + + await visit("/chat/channel/11/another-category"); + assert.equal(query(".chat-composer-input").value.trim(), "Hi people"); + await fillIn(".chat-composer-input", ""); + + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), "What up what up"); + + // Send a message + const composerTextarea = query(".chat-composer-input"); + await focus(composerTextarea); + await triggerKeyEvent(composerTextarea, "keydown", "Enter"); + + assert.equal(query(".chat-composer-input").value.trim(), ""); + + // Navigate away and back to make sure input didn't re-fill + await visit("/chat/channel/11/another-category"); + await visit("/chat/channel/75/@hawk"); + assert.equal(query(".chat-composer-input").value.trim(), ""); + }); + + test("Pressing escape cancels editing", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container[data-id='174']", "mouseenter"); + + const dropdown = selectKit(".more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("edit"); + + assert.ok(exists(".chat-composer-message-details")); + await triggerKeyEvent(".chat-composer", "keydown", "Escape"); + + // chat-composer-message-details will be gone as no message is being edited + assert.notOk(exists(".chat-composer .chat-composer-message-details")); + }); + + test("Unread indicator increments for public channels when messages come in", async function (assert) { + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists(".header-dropdown-toggle.open-chat .chat-channel-unread-indicator") + ); + + await publishToMessageBus("/chat/9/new-messages", { + message_id: 201, + user_id: 2, + }); + + assert.ok( + exists(".header-dropdown-toggle.open-chat .chat-channel-unread-indicator") + ); + }); + + test("Unread count increments for direct message channels when messages come in", async function (assert) { + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + + await publishToMessageBus("/chat/75/new-messages", { + message_id: 201, + user_id: 2, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.equal( + query( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ).innerText.trim(), + 1 + ); + }); + + test("Unread DM count overrides the public unread indicator", async function (assert) { + await visit("/t/internationalization-localization/280"); + await publishToMessageBus("/chat/9/new-messages", { + message_id: 201, + user_id: 2, + }); + await publishToMessageBus("/chat/75/new-messages", { + message_id: 202, + user_id: 2, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator:not(.urgent)" + ) + ); + }); + + test("Mentions in public channels show the unread urgent indicator", async function (assert) { + await visit("/t/internationalization-localization/280"); + await publishToMessageBus("/chat/9/new-mentions", { + message_id: 201, + }); + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator.urgent .number" + ) + ); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator:not(.urgent)" + ) + ); + }); + + test("message selection and live pane buttons for regular user", async function (assert) { + updateCurrentUser({ admin: false, moderator: false }); + await visit("/chat/channel/11/another-category"); + + const firstMessage = query(".chat-message-container"); + await triggerEvent(firstMessage, "mouseenter"); + const dropdown = selectKit( + `.chat-msgactions-hover[data-id="${firstMessage.dataset.id}"] .more-buttons` + ); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + + assert.ok(firstMessage.classList.contains("selecting-messages")); + assert.ok(exists("#chat-quote-btn")); + }); + + test("message selection is not present for regular user", async function (assert) { + updateCurrentUser({ admin: false, moderator: false }); + await visit("/chat/channel/11/another-category"); + assert.notOk( + exists(".chat-message-container .chat-msgactions-hover .select-btn") + ); + }); + + test("creating a new direct message channel works", async function (assert) { + await visit("/chat/channel/11/another-category"); + await click(".new-dm"); + await fillIn(".filter-usernames", "hawk"); + await click("li.user[data-username='hawk']"); + + assert.notOk( + query(".join-channel-btn"), + "Join channel button is not present" + ); + const enabledComposer = document.querySelector(".chat-composer-input"); + assert.ok(!enabledComposer.disabled); + assert.equal( + enabledComposer.placeholder, + I18n.t("chat.placeholder_start_conversation", { usernames: "hawk" }) + ); + }); + + test("creating a new direct message channel from popup chat works", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".new-dm"); + await fillIn(".filter-usernames", "hawk"); + await click('.chat-user-avatar-container[data-user-card="hawk"]'); + assert.ok(query(".selected-user").innerText, "hawk"); + }); + + test("Reacting works with no existing reactions", async function (assert) { + await visit("/chat/channel/11/another-category"); + const message = query(".chat-message-container"); + await triggerEvent(message, "mouseenter"); + assert.notOk(message.querySelector(".chat-message-reaction-list")); + await click(".chat-msgactions .react-btn"); + await click(`.chat-emoji-picker .emoji[alt="grinning"]`); + + assert.ok(message.querySelector(".chat-message-reaction-list")); + const reaction = message.querySelector( + ".chat-message-reaction-list .chat-message-reaction.reacted" + ); + assert.ok(reaction); + assert.equal(reaction.querySelector(".count").innerText.trim(), 1); + }); + + test("Reacting works with existing reactions", async function (assert) { + await visit("/chat/channel/11/another-category"); + const messages = queryAll(".chat-message-container"); + + // First 2 messages have no reactions; make sure the list isn't rendered + assert.notOk(messages[0].querySelector(".chat-message-reaction-list")); + assert.notOk(messages[1].querySelector(".chat-message-reaction-list")); + + const lastMessage = messages[2]; + assert.ok(lastMessage.querySelector(".chat-message-reaction-list")); + assert.equal( + lastMessage.querySelectorAll(".chat-message-reaction.reacted").length, + 2 + ); + assert.equal( + lastMessage.querySelectorAll(".chat-message-reaction:not(.reacted)") + .length, + 1 + ); + + // React with a heart and make sure the count increments and class is added + const heartReaction = lastMessage.querySelector( + `.chat-message-reaction[data-emoji-name="heart"]` + ); + assert.equal(heartReaction.innerText.trim(), "1"); + await click(heartReaction); + assert.equal(heartReaction.innerText.trim(), "2"); + assert.ok(heartReaction.classList.contains("reacted")); + + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 1, username: "eviltrout" }, + emoji: "heart", + type: "reaction", + chat_message_id: 176, + }); + + // Click again make sure count goes down + await click(heartReaction); + assert.equal(heartReaction.innerText.trim(), "1"); + assert.notOk(heartReaction.classList.contains("reacted")); + + // Message from another user coming in! + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 77, username: "rando" }, + emoji: "sneezing_face", + type: "reaction", + chat_message_id: 176, + }); + const sneezingFaceReaction = lastMessage.querySelector( + `.chat-message-reaction[data-emoji-name="sneezing_face"]` + ); + assert.ok(sneezingFaceReaction); + assert.equal(sneezingFaceReaction.innerText.trim(), "1"); + assert.notOk(sneezingFaceReaction.classList.contains("reacted")); + await click(sneezingFaceReaction); + assert.equal(sneezingFaceReaction.innerText.trim(), "2"); + assert.ok(sneezingFaceReaction.classList.contains("reacted")); + }); + + test("Reacting and unreacting works on newly created chat messages", async function (assert) { + await visit("/chat/channel/11/another-category"); + const composerInput = query(".chat-composer-input"); + await fillIn(composerInput, "hellloooo"); + await focus(composerInput); + await triggerKeyEvent(composerInput, "keydown", "Enter"); + + const messages = queryAll(".chat-message-container"); + const lastMessage = messages[messages.length - 1]; + await publishToMessageBus("/chat/11", { + type: "sent", + stagedId: 1, + chat_message: { + id: 202, + user: { + id: 1, + }, + cooked: "

hellloooo

", + }, + }); + + assert.deepEqual(lastMessage.dataset.id, "202"); + await triggerEvent(lastMessage, "mouseenter"); + await click( + `.chat-msgactions-hover[data-id="${lastMessage.dataset.id}"] .react-btn` + ); + await click(`.emoji[alt="grinning"]`); + + const reaction = lastMessage.querySelector( + `.chat-message-reaction.reacted[data-emoji-name="grinning"]` + ); + + await publishToMessageBus("/chat/11", { + action: "add", + user: { id: 1, username: "eviltrout" }, + emoji: "grinning", + type: "reaction", + chat_message_id: 202, + }); + await click(reaction); + + assert.notOk( + lastMessage.querySelector( + `.chat-message-reaction.reacted[data-emoji-name="grinning"]` + ) + ); + }); + + test("mention warning is rendered", async function (assert) { + await visit("/chat/channel/11/another-category"); + await publishToMessageBus("/chat/11", { + type: "mention_warning", + cannot_see: [{ id: 75, username: "hawk" }], + without_membership: [ + { id: 76, username: "eviltrout" }, + { id: 77, username: "sam" }, + ], + chat_message_id: 176, + }); + + assert.ok( + exists( + ".chat-message-container[data-id='176'] .chat-message-mention-warning" + ) + ); + + assert.ok( + query( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .cannot-see" + ).innerText.includes("hawk") + ); + + const withoutMembershipText = query( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .without-membership" + ).innerText; + assert.ok(withoutMembershipText.includes("eviltrout")); + assert.ok(withoutMembershipText.includes("sam")); + + await click( + ".chat-message-container[data-id='176'] .chat-message-mention-warning .invite-link" + ); + assert.notOk( + exists( + ".chat-message-container[data-id='176'] .chat-message-mention-warning" + ) + ); + }); + + test("It displays a separator between days", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.equal( + query(".first-daily-message").innerText.trim(), + "July 22, 2021" + ); + }); + + test("pressing keys focuses composer in full page chat", async function (assert) { + await visit("/chat/channel/11/another-category"); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 65); // 65 is `a` keycode + let composer = query(".chat-composer-input"); + assert.equal(composer.value, "a"); + assert.equal(document.activeElement, composer); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 65); + assert.equal(composer.value, "aa"); + assert.equal(document.activeElement, composer); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", 191); // 191 is `?` + assert.notEqual( + document.activeElement, + composer, + "? is a special case and should not focus" + ); + + document.activeElement.blur(); + await triggerKeyEvent(document.body, "keydown", "Enter"); + assert.notEqual( + document.activeElement, + composer, + "enter is a special case and should not focus" + ); + }); + + test("changing channel resets message selection", async function (assert) { + await visit("/chat/channel/11/another-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + await dropdown.selectRowByValue("selectMessage"); + await click("#chat-copy-btn"); + await click("#chat-channel-row-9"); + + assert.notOk(exists("#chat-copy-btn")); + }); +}); + +acceptance( + "Discourse Chat - Acceptance Test with unread public channel messages", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper, [ + { id: 11, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Expand button takes you to full page chat on the correct channel", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await visit(".header-dropdown-toggle.open-chat"); + await click(".topic-chat-drawer-header__full-screen-btn"); + + assert.equal(currentURL(), `/chat/channel/11/another-category`); + }); + + test("Chat opens to full-page channel with unread messages when sidebar is installed", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", true); + + await click(".header-dropdown-toggle.open-chat"); + + assert.equal(currentURL(), `/chat/channel/11/another-category`); + assert.notOk( + visible(".topic-chat-float-container"), + "chat float is not open" + ); + }); + + test("Chat float opens on header icon click when sidebar is not installed", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + }); + + test("Unread header indicator is present", async function (assert) { + await visit("/t/internationalization-localization/280"); + + assert.ok( + exists( + ".header-dropdown-toggle.open-chat .chat-channel-unread-indicator" + ), + "Unread indicator present in header" + ); + }); + } +); + +acceptance( + "Discourse Chat - Acceptance Test show/hide close fullscreen chat button", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Close fullscreen chat button present", async function (assert) { + await visit("/chat/channel/11/another-category"); + assert.ok(exists(".chat-full-screen-button")); + }); + } +); + +acceptance( + "Discourse Chat - Expand and collapse chat drawer (topic-chat-float)", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + ]); + + server.get("/chat/api/chat_channels/:id/memberships.json", () => { + return helper.response([]); + }); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("chat drawer can be collapsed and expanded", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + await click(".topic-chat-drawer-header__expand-btn"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--collapsed"), + "chat float is collapsed" + ); + await click(".topic-chat-drawer-header__expand-btn"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + }); + + test("chat drawer title links to channel info when expanded", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + await click(".topic-chat-drawer-header__title"); + assert.equal(currentURL(), `/chat/channel/9/site/info/members`); + }); + + test("chat drawer title expands the chat drawer when collapsed", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + await click(".topic-chat-drawer-header__expand-btn"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--collapsed"), + "chat float is collapsed" + ); + await click(".topic-chat-drawer-header__title"); + assert.ok( + visible(".topic-chat-drawer-header__top-line--expanded"), + "chat float is expanded" + ); + }); + } +); + +acceptance( + "Discourse Chat - Acceptance Test with unread DMs and public channel messages", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + // chat channel with ID 75 is direct message channel. + chatChannelPretender(server, helper, [ + { id: 9, unread_count: 2, muted: false }, + { id: 75, unread_count: 2, muted: false }, + ]); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Unread indicator doesn't show when user is in do not disturb", async function (assert) { + let now = new Date(); + let later = new Date(); + later.setTime(now.getTime() + 600000); + updateCurrentUser({ do_not_disturb_until: later.toUTCString() }); + await visit("/t/internationalization-localization/280"); + assert.notOk( + exists( + ".header-dropdown-toggle.open-chat .chat-unread-urgent-indicator" + ) + ); + }); + + test("Chat float open to DM channel with unread messages with sidebar off", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + await click(".header-dropdown-toggle.open-chat"); + const chatContainer = query(".topic-chat-container"); + assert.ok(chatContainer.classList.contains("channel-75")); + }); + + test("Chat full page open to DM channel with unread messages with sidebar on", async function (assert) { + this.chatService.set("sidebarActive", true); + await visit("/t/internationalization-localization/280"); + await click(".header-dropdown-toggle.open-chat"); + + assert.equal(currentURL(), `/chat/channel/75/hawk`); + }); + } +); + +acceptance( + "Discourse Chat - chat channel settings and creation", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + + const channel = { + chatable: {}, + chatable_id: 88, + chatable_type: "Category", + chatable_url: null, + id: 88, + title: "Something", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }; + + server.get("/chat/api/chat_channels.json", () => { + return helper.response([fabricators.chatChannel()]); + }); + + server.get("/chat/chat_channels/:id.json", () => { + return helper.response(channel); + }); + + server.put("/chat/chat_channels", () => { + return helper.response({ + chat_channel: channel, + }); + }); + }); + + test("Create channel modal", async function (assert) { + this.container.lookup("service:chat").set("chatWindowFullPage", true); + + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual(currentURL(), "/chat/browse/open"); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByValue("6"); // Category 6 is "support" + assert.strictEqual( + query(".create-channel-modal .create-channel-name-input").value.trim(), + "support" + ); + assert.notOk(query(".create-channel-modal .btn.create").disabled); + + await click(".create-channel-modal .btn.create"); + assert.strictEqual(currentURL(), "/chat/channel/88/something"); + }); + } +); + +acceptance("Discourse Chat - chat preferences", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Chat preferences route takes user to homepage when can_chat is false", async function (assert) { + updateCurrentUser({ can_chat: false }); + await visit("/u/eviltrout/preferences/chat"); + assert.equal(currentURL(), "/latest"); + }); + + test("There are all 5 settings shown", async function (assert) { + this.chatService.set("sidebarActive", true); + await visit("/u/eviltrout/preferences/chat"); + assert.equal(currentURL(), "/u/eviltrout/preferences/chat"); + assert.equal(queryAll(".chat-setting").length, 5); + }); + + test("The user can save the settings", async function (assert) { + updateCurrentUser({ has_chat_enabled: false }); + const spy = sinon.spy(ajaxModule, "ajax"); + await visit("/u/eviltrout/preferences/chat"); + await click("#user_chat_enabled"); + await click("#user_chat_only_push_notifications"); + await click("#user_chat_ignore_channel_wide_mention"); + await selectKit("#user_chat_sounds").expand(); + await selectKit("#user_chat_sounds").selectRowByValue("bell"); + await selectKit("#user_chat_email_frequency").expand(); + await selectKit("#user_chat_email_frequency").selectRowByValue("never"); + + await click(".save-changes"); + + assert.ok( + spy.calledWithMatch("/u/eviltrout.json", { + data: { + chat_enabled: true, + chat_sound: "bell", + only_chat_push_notifications: true, + ignore_channel_wide_mention: true, + chat_email_frequency: "never", + }, + type: "PUT", + }), + "is able to save the chat preferences for the user" + ); + }); +}); + +acceptance("Discourse Chat - plugin API", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + + test("defines a decorateChatMessage plugin API", async function (assert) { + withPluginApi("1.1.0", (api) => { + api.decorateChatMessage((message) => { + message.innerText = "test"; + }); + }); + + await visit("/chat/channel/75/@hawk"); + + assert.equal( + document.querySelector('.chat-message-container[data-id="177"]') + .innerText, + "test" + ); + }); +}); + +acceptance("Discourse Chat - image uploads", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + chat_allow_uploads: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + + server.post( + "/uploads.json", + () => { + return helper.response({ + extension: "jpeg", + filesize: 126177, + height: 800, + human_filesize: "123 KB", + id: 202, + original_filename: "avatar.PNG.jpg", + retain_hours: null, + short_path: "/images/avatar.png", + short_url: "upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + thumbnail_height: 320, + thumbnail_width: 690, + url: "/images/avatar.png", + width: 1920, + }); + }, + 500 // this delay is important to slow down the uploads a bit so we can click elements in the UI like the cancel button + ); + }); + + test("uploading files in chat works", async function (assert) { + await visit("/t/internationalization-localization/280"); + this.container.lookup("service:chat").set("sidebarActive", false); + this.container.lookup("service:chat").set("chatWindowFullPage", false); + await click(".header-dropdown-toggle.open-chat"); + + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + + const appEvents = loggedInUser().appEvents; + const done = assert.async(); + + appEvents.on( + "upload-mixin:chat-composer-uploader:all-uploads-complete", + async () => { + await settled(); + assert.ok( + exists(".preview .preview-img"), + "the chat upload preview should show" + ); + assert.notOk( + exists(".bottom-data .uploading"), + "the chat upload preview should no longer say it is uploading" + ); + assert.strictEqual( + queryAll(".chat-composer-input").val(), + "", + "the chat composer does not get the upload markdown when the upload is complete" + ); + done(); + } + ); + + appEvents.on( + "upload-mixin:chat-composer-uploader:upload-started", + async () => { + await settled(); + assert.ok( + exists(".chat-upload"), + "the chat upload preview should show" + ); + assert.ok( + exists(".bottom-data .uploading"), + "the chat upload preview should say it is uploading" + ); + assert.strictEqual( + queryAll(".chat-composer-input").val(), + "", + "the chat composer does not get an uploading... placeholder" + ); + } + ); + + const image = createFile("avatar.png"); + appEvents.trigger("upload-mixin:chat-composer-uploader:add-files", image); + }); + + test("uploading files in composer does not insert placeholder text into chat composer", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + this.container.lookup("service:chat").set("sidebarActive", false); + this.container.lookup("service:chat").set("chatWindowFullPage", false); + await click(".header-dropdown-toggle.open-chat"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + + const appEvents = loggedInUser().appEvents; + const done = assert.async(); + await fillIn(".d-editor-input", "The image:\n"); + + appEvents.on("composer:all-uploads-complete", async () => { + await settled(); + assert.strictEqual( + query(".d-editor-input").value, + "The image:\n![avatar.PNG|690x320](upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg)\n", + "the topic composer gets the completed image markdown" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "the chat composer does not get the completed image markdown" + ); + done(); + }); + + appEvents.on("composer:upload-started", () => { + assert.strictEqual( + query(".d-editor-input").value, + "The image:\n[Uploading: avatar.png...]()\n", + "the topic composer gets the placeholder image markdown" + ); + assert.strictEqual( + query(".chat-composer-input").value, + "", + "the chat composer does not get the placeholder image markdown" + ); + }); + + const image = createFile("avatar.png"); + appEvents.trigger("composer:add-files", image); + }); +}); + +acceptance( + "Discourse Chat - image uploads - uploads not allowed", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + chat_allow_uploads: false, + discourse_local_dates_enabled: false, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + directMessageChannelPretender(server, helper); + chatChannelPretender(server, helper); + }); + + test("uploads are not allowed in public channels", async function (assert) { + await visit("/chat/channel/4/public-category"); + await click(".chat-composer-dropdown__trigger-btn"); + + assert.notOk( + exists(".chat-composer-dropdown__item.chat-upload-btn"), + "composer dropdown should not be visible because uploads are not enabled and no other buttons are rendered" + ); + }); + + test("uploads are not allowed in direct message channels", async function (assert) { + await visit("/chat/channel/75/@hawk"); + await click(".chat-composer-dropdown__trigger-btn"); + + assert.notOk( + exists(".chat-composer-dropdown__item.chat-upload-btn"), + "composer dropdown should not be visible because uploads are not enabled and no other buttons are rendered" + ); + }); + } +); + +acceptance("Discourse Chat - Insert Date", function (needs) { + needs.user({ + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + discourse_local_dates_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + test("can use local date modal", async function (assert) { + await visit("/chat/channel/4/public-category"); + await click(".chat-composer-dropdown__trigger-btn"); + await click(".chat-composer-dropdown__action-btn.local-dates"); + + assert.ok(exists(".discourse-local-dates-create-modal")); + + await click(".modal-footer .btn-primary"); + + assert.ok( + query(".chat-composer-input").value.startsWith("[date"), + "inserts date in composer input" + ); + }); +}); + +acceptance( + "Discourse Chat - Channel Status - Read only channel", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.readOnly; + return helper.response(cloned); + }); + }); + + test("read only channel composer is disabled", async function (assert) { + await visit("/chat/channel/5/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, true); + }); + + test("read only channel header status shows correct information", async function (assert) { + await visit("/chat/channel/5/public-category"); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.read_only_header") + ); + }); + + test("read only channels do not show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/5/public-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + assert.notOk(exists(".select-kit-row[data-value='edit']")); + assert.notOk(exists(".select-kit-row[data-value='deleteMessage']")); + assert.notOk(exists(".select-kit-row[data-value='rebakeMessage']")); + assert.notOk(exists(".reply-btn")); + assert.notOk(exists(".react-btn")); + }); + } +); + +acceptance( + "Discourse Chat - Channel Status - Closed channel (regular user)", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 4).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("closed channel composer is disabled", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, true); + }); + + test("closed channel header status shows correct information", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual( + query(".chat-channel-status").innerText.trim(), + I18n.t("chat.channel_status.closed_header") + ); + }); + + test("closed channels do not show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/4/public-category"); + + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + + assert.notOk(exists(".select-kit-row[data-value='edit']")); + assert.notOk(exists(".select-kit-row[data-value='deleteMessage']")); + assert.notOk(exists(".select-kit-row[data-value='rebakeMessage']")); + assert.notOk(exists(".reply-btn")); + assert.notOk(exists(".react-btn")); + }); + } +); + +acceptance( + "Discourse Chat - Channel Status - Closed channel (staff user)", + function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("closed channel composer is enabled", async function (assert) { + await visit("/chat/channel/4/public-category"); + assert.strictEqual(query(".chat-composer-input").disabled, false); + }); + + test("closed channels show the reply, react, delete, edit, restore, or rebuild options for messages", async function (assert) { + await visit("/chat/channel/4/public-category"); + await triggerEvent(".chat-message-container", "mouseenter"); + const dropdown = selectKit(".chat-msgactions .more-buttons"); + await dropdown.expand(); + assert.ok( + exists(".select-kit-row[data-value='edit']"), + "the edit message button is shown" + ); + assert.ok( + exists(".select-kit-row[data-value='deleteMessage']"), + "the delete message button is shown" + ); + assert.ok( + exists(".select-kit-row[data-value='rebakeMessage']"), + "the rebake message button is shown" + ); + assert.ok(exists(".reply-btn", "the reply button is shown")); + assert.ok(exists(".react-btn"), "the react button is shown"); + }); + } +); + +acceptance("Discourse Chat - Channel Replying Indicator", function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + server.get("/chat/chat_channels.json", () => { + const cloned = cloneJSON(chatChannels); + cloned.public_channels.find((chan) => chan.id === 7).status = + CHANNEL_STATUSES.closed; + return helper.response(cloned); + }); + }); + + test("indicator content when replying/not replying", async function (assert) { + const user = { id: 8, username: "bob" }; + await visit("/chat/channel/4/public-category"); + await joinChannel("/chat-reply/4", user); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + I18n.t("chat.replying_indicator.single_user", { + username: user.username, + }) + ); + + await leaveChannel("/chat-reply/4", user); + + assert.notOk(exists(".chat-replying-indicator__text")); + }); +}); + +acceptance("Discourse Chat - Direct Message Creator", function (needs) { + needs.user({ + admin: true, + moderator: true, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.settings({ + chat_enabled: true, + }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + + server.get("/u/search/users", () => { + return helper.response([]); + }); + }); + + test("Create a direct message", async function (assert) { + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + await click(".topic-chat-drawer-header__return-to-channels-btn"); + assert.ok( + !exists(".new-dm.btn-floating"), + "mobile floating button should not exist on desktop" + ); + await click(".btn.new-dm"); + assert.ok(exists(".chat-draft"), "view changes to draft channel screen"); + }); +}); + +acceptance("Discourse Chat - Drawer", function (needs) { + needs.user({ has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("Position after closing reduced composer", async function (assert) { + this.chatService.set("chatWindowFullPage", false); + + await visit("/t/internationalization-localization/280"); + await click(".btn.create"); + await click(".toggle-preview"); + await click(".header-dropdown-toggle.open-chat"); + await click(".save-or-cancel .cancel"); + const float = document.querySelector(".topic-chat-float-container"); + const key = "--composer-right"; + const value = getComputedStyle(float).getPropertyValue(key); + + assert.strictEqual(value, "15px"); + }); +}); + +function createFile(name, type = "image/png") { + // the blob content doesn't matter at all, just want it to be random-ish + const file = new Blob([(Math.random() + 1).toString(36).substring(2)], { + type, + }); + file.name = name; + return file; +} diff --git a/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js b/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js new file mode 100644 index 00000000000..6321bbead12 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-transcript-test.js @@ -0,0 +1,580 @@ +import PrettyText, { buildOptions } from "pretty-text/pretty-text"; +import { emojiUnescape } from "discourse/lib/text"; +import I18n from "I18n"; +import topicFixtures from "discourse/tests/fixtures/topic"; +import { cloneJSON, deepMerge } from "discourse-common/lib/object"; +import QUnit, { test } from "qunit"; + +import { click, fillIn, visit } from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; + +const rawOpts = { + siteSettings: { + enable_emoji: true, + enable_emoji_shortcuts: true, + enable_mentions: true, + emoji_set: "twitter", + external_emoji_url: "", + highlighted_languages: "json|ruby|javascript", + default_code_lang: "auto", + enable_markdown_linkify: true, + markdown_linkify_tlds: "com", + chat_enabled: true, + }, + getURL: (url) => url, +}; + +function cookMarkdown(input, opts) { + const merged = deepMerge({}, rawOpts, opts); + return new PrettyText(buildOptions(merged)).cook(input); +} + +QUnit.assert.cookedChatTranscript = function (input, opts, expected, message) { + const actual = cookMarkdown(input, opts); + this.pushResult({ + result: actual === expected, + actual, + expected, + message, + }); +}; + +function generateTranscriptHTML(messageContent, opts) { + const channelDataAttr = opts.channel + ? ` data-channel-name=\"${opts.channel}\"` + : ""; + const channelIdDataAttr = opts.channelId + ? ` data-channel-id=\"${opts.channelId}\"` + : ""; + const reactDataAttr = opts.reactions + ? ` data-reactions=\"${opts.reactionsAttr}\"` + : ""; + + let tabIndexHTML = opts.linkTabIndex ? ' tabindex="-1"' : ""; + + let transcriptClasses = ["chat-transcript"]; + if (opts.chained) { + transcriptClasses.push("chat-transcript-chained"); + } + + const transcript = []; + transcript.push( + `
` + ); + + if (opts.channel && opts.multiQuote) { + let originallySent = I18n.t("chat.quote.original_channel", { + channel: opts.channel, + channelLink: `/chat/channel/${opts.channelId}/-`, + }); + if (opts.linkTabIndex) { + originallySent = originallySent.replace(">", tabIndexHTML + ">"); + } + transcript.push(`
+${originallySent}
`); + } + + const dateTimeText = opts.showDateTimeText + ? moment + .tz(opts.datetime, opts.timezone) + .format(I18n.t("dates.long_no_year")) + : ""; + + const innerDatetimeEl = + opts.noLink || !opts.channelId + ? `${dateTimeText}` + : `${dateTimeText}`; + transcript.push(`
+
+
+${opts.username}
+
+${innerDatetimeEl}
`); + + if (opts.channel && !opts.multiQuote) { + transcript.push( + ` +#${opts.channel}
` + ); + } else { + transcript.push("
"); + } + + let messageHtml = `
\n${messageContent}`; + + if (opts.reactions) { + let reactionsHtml = [`
\n`]; + opts.reactions.forEach((react) => { + reactionsHtml.push( + `
\n${emojiUnescape( + `:${react.emoji}:`, + { lazy: true } + ).replace(/'/g, '"')} ${react.usernames.length}
\n` + ); + }); + reactionsHtml.push(`
\n`); + messageHtml += reactionsHtml.join(""); + } + transcript.push(`${messageHtml}
`); + transcript.push("
"); + return transcript.join("\n"); +} + +// these are both set by the plugin with Site.markdown_additional_options which we can't really +// modify the response for here, source of truth are consts in ChatMessage::MARKDOWN_FEATURES +// and ChatMessage::MARKDOWN_IT_RULES +function buildAdditionalOptions() { + return { + chat: { + limited_pretty_text_features: [ + "anchor", + "bbcode-block", + "bbcode-inline", + "code", + "category-hashtag", + "censored", + "discourse-local-dates", + "emoji", + "emojiShortcuts", + "inlineEmoji", + "html-img", + "mentions", + "onebox", + "text-post-process", + "upload-protocol", + "watched-words", + "table", + "spoiler-alert", + ], + limited_pretty_text_markdown_rules: [ + "autolink", + "list", + "backticks", + "newline", + "code", + "fence", + "table", + "linkify", + "link", + "strikethrough", + "blockquote", + "emphasis", + ], + }, + }; +} + +acceptance("Discourse Chat | chat-transcript", function (needs) { + let additionalOptions = buildAdditionalOptions(); + + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: false, + has_chat_enabled: false, + timezone: "Australia/Brisbane", + }); + + needs.settings({ + emoji_set: "twitter", + }); + + test("works with a minimal quote bbcode block", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + }), + "renders the chat message with the required CSS classes and attributes" + ); + }); + + test("renders the channel name if provided with multiQuote", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + timezone: "Australia/Brisbane", + }), + "renders the chat transcript with the channel name included above the user and datetime" + ); + }); + + test("renders the channel name if provided without multiQuote", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + timezone: "Australia/Brisbane", + }), + "renders the chat transcript with the channel name included next to the datetime" + ); + }); + + test("renders with the chained attribute for more compact quotes", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" chained="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + chained: true, + timezone: "Australia/Brisbane", + }), + "renders with the chained attribute" + ); + }); + + test("renders with the noLink attribute to remove the links to the individual messages from the datetimes", function (assert) { + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" noLink="true"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + noLink: true, + timezone: "Australia/Brisbane", + }), + "renders with the noLink attribute" + ); + }); + + test("renders with the reactions attribute", function (assert) { + const reactionsAttr = "+1:martin;heart:martin,eviltrout"; + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" reactions="${reactionsAttr}"]\nThis is a chat message.\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML("

This is a chat message.

", { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + timezone: "Australia/Brisbane", + reactionsAttr, + reactions: [ + { emoji: "+1", usernames: ["martin"] }, + { emoji: "heart", usernames: ["martin", "eviltrout"] }, + ], + }), + "renders with the reaction data attribute and HTML" + ); + }); + + test("renders with minimal markdown rules inside the quote bbcode block, same as server-side chat messages", function (assert) { + assert.cookedChatTranscript( + `[chat quote="johnsmith;450;2021-04-25T05:40:39Z"] +[quote="martin, post:3, topic:6215"] +another cool reply +[/quote] +[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

[quote="martin, post:3, topic:6215"]
+another cool reply
+[/quote]

`, + { + messageId: "450", + username: "johnsmith", + datetime: "2021-04-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "does not render the markdown feature that has been excluded" + ); + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

This does work with removed rules.

+
    +
  • list item 1
  • +
`, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly when the rule has not been excluded" + ); + + additionalOptions.chat.limited_pretty_text_markdown_rules = [ + "autolink", + // "list", + "backticks", + "newline", + "code", + "fence", + "table", + "linkify", + "link", + // "strikethrough", + "blockquote", + // "emphasis", + ]; + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

This ~~does work~~ with removed _rules_.

+

* list item 1

`, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly with some obvious rules excluded (list/strikethrough/emphasis)" + ); + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

here is a message \":stuck_out_tongue:\" with category hashtag #test

`, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly when the feature has not been excluded" + ); + + additionalOptions.chat.limited_pretty_text_features = [ + "anchor", + "bbcode-block", + "bbcode-inline", + "code", + // "category-hashtag", + "censored", + "discourse-local-dates", + "emoji", + // "emojiShortcuts", + "inlineEmoji", + "html-img", + "mentions", + "onebox", + "text-post-process", + "upload-protocolrouter.location.setURL", + "watched-words", + "table", + "spoiler-alert", + ]; + + assert.cookedChatTranscript( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`, + { additionalOptions }, + generateTranscriptHTML( + `

here is a message :P with category hashtag #test

`, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "renders correctly with some obvious features excluded (category-hashtag, emojiShortcuts)" + ); + + assert.cookedChatTranscript( + `This ~~does work~~ with removed _rules_. + +* list item 1 + +here is a message :P with category hashtag #test + +[chat quote="martin;2321;2022-01-25T05:40:39Z"] +This ~~does work~~ with removed _rules_. + +* list item 1 + +here is a message :P with category hashtag #test +[/chat]`, + { additionalOptions }, + `

This does work with removed rules.

+
    +
  • list item 1
  • +
+

here is a message \":stuck_out_tongue:\" with category hashtag #test

\n` + + generateTranscriptHTML( + `

This ~~does work~~ with removed _rules_.

+

* list item 1

+

here is a message :P with category hashtag #test

`, + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + timezone: "Australia/Brisbane", + } + ), + "the rule changes do not apply outside the BBCode [chat] block" + ); + }); +}); + +acceptance( + "Discourse Chat | chat-transcript date decoration", + function (needs) { + let additionalOptions = buildAdditionalOptions(); + + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + timezone: "Australia/Brisbane", + }); + needs.settings({ + chat_enabled: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + const postCooked = cookMarkdown( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions } + ); + firstPost.cooked += postCooked; + + server.get("/t/280.json", () => helper.response(topicResponse)); + }); + + test("chat transcript datetimes are formatted into the link with decorateCookedElement", async function (assert) { + await visit("/t/-/280"); + + assert.strictEqual( + query(".chat-transcript-datetime span").innerText.trim(), + moment + .tz("2022-01-25T05:40:39Z", "Australia/Brisbane") + .format(I18n.t("dates.long_no_year")), + "it decorates the chat transcript datetime link with a formatted date" + ); + }); + } +); + +acceptance( + "Discourse Chat - chat-transcript - Composer Oneboxes ", + function (needs) { + let additionalOptions = buildAdditionalOptions(); + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + timezone: "Australia/Brisbane", + }); + needs.settings({ + chat_enabled: true, + enable_markdown_linkify: true, + max_oneboxes_per_post: 2, + }); + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + const postCooked = cookMarkdown( + `[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`, + { additionalOptions } + ); + firstPost.cooked += postCooked; + + server.get("/t/280.json", () => helper.response(topicResponse)); + }); + + test("Preview should not error for oneboxes within [chat] bbcode", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("#topic-footer-buttons .btn.create"); + + await fillIn( + ".d-editor-input", + ` +[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"] +http://www.example.com/has-title.html +[/chat]` + ); + + const rendered = generateTranscriptHTML( + '

', + { + messageId: "2321", + username: "martin", + datetime: "2022-01-25T05:40:39Z", + channel: "Cool Cats Club", + channelId: "1234", + multiQuote: true, + linkTabIndex: true, + showDateTimeText: true, + timezone: "Australia/Brisbane", + } + ); + + assert.strictEqual( + query(".d-editor-preview").innerHTML.trim(), + rendered.trim(), + "it renders correctly with the onebox inside the [chat] bbcode" + ); + + const textarea = query("#reply-control .d-editor-input"); + await fillIn(".d-editor-input", textarea.value + "\nA"); + assert.ok( + query(".d-editor-preview").innerHTML.trim().includes("\n

A

"), + "it does not error with a opts.discourse.hoisted error in the markdown pipeline when typing more text" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js b/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js new file mode 100644 index 00000000000..866def7056f --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/chat-user-menu-notifications-test.js @@ -0,0 +1,252 @@ +import I18n from "I18n"; +import { test } from "qunit"; + +import { click, visit } from "@ember/test-helpers"; + +import { + acceptance, + exists, + query, + queryAll, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; + +import { + baseChatPretenders, + chatChannelPretender, +} from "../helpers/chat-pretenders"; + +acceptance( + "Discourse Chat - experiment user menu notifications - user cannot chat", + function (needs) { + needs.user({ has_chat_enabled: false }); + needs.settings({ chat_enabled: false }); + + test("chat notifications tab is not displayed in user menu", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + assert.notOk( + exists("#user-menu-button-chat-notifications"), + "button for chat notifications tab is not displayed" + ); + }); + } +); + +acceptance( + "Discourse Chat - experimental user menu notifications ", + function (needs) { + needs.user({ redesigned_user_menu_enabled: true, has_chat_enabled: true }); + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + baseChatPretenders(server, helper); + chatChannelPretender(server, helper); + }); + + test("chat notifications tab", async function (assert) { + updateCurrentUser({ + grouped_unread_notifications: { + 29: 3, // chat_mention notification type + 31: 1, // chat_invitation notification type + }, + }); + + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + assert.ok( + exists("#user-menu-button-chat-notifications"), + "button for chat notifications tab is displayed" + ); + + assert.ok( + exists("#user-menu-button-chat-notifications .d-icon-comment"), + "displays the comment icon for chat notification tab button" + ); + + assert.strictEqual( + query("#user-menu-button-chat-notifications .badge-notification") + .textContent, + "4", + "displays the right badge count for chat notifications tab button" + ); + }); + + test("chat mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatMentionNotificationLink = queryAll(".chat-mention a")[0]; + + assert.strictEqual( + chatMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned you in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists(chatMentionNotificationLink.querySelector(".d-icon-comment")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("personal chat mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const personalChatMentionNotificationLink = + queryAll(".chat-mention a")[3]; + + assert.strictEqual( + personalChatMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + "hawk mentioned you in personal chat", + "displays the right text for notification" + ); + + assert.ok( + exists( + personalChatMentionNotificationLink.querySelector(".d-icon-comment") + ), + "displays the right icon for the notification" + ); + + assert.strictEqual( + personalChatMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + personalChatMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat group mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatGroupMentionNotificationLink = queryAll(".chat-mention a")[1]; + + assert.strictEqual( + chatGroupMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned @engineers in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists( + chatGroupMentionNotificationLink.querySelector(".d-icon-comment") + ), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatGroupMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatGroupMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat all mention notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatAllMentionNotificationLink = queryAll(".chat-mention a")[2]; + + assert.strictEqual( + chatAllMentionNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + 'hawk mentioned @all in "Site"', + "displays the right text for notification" + ); + + assert.ok( + exists(chatAllMentionNotificationLink.querySelector(".d-icon-comment")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatAllMentionNotificationLink.title, + I18n.t("notifications.titles.chat_mention"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatAllMentionNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + + test("chat invite notification link", async function (assert) { + await visit("/"); + await click(".header-dropdown-toggle.current-user"); + + const chatInviteNotificationLink = queryAll(".chat-invitation a")[0]; + + assert.strictEqual( + chatInviteNotificationLink.textContent + .trim() + .replace(/\n/g, "") + .replace(/\s+/, " "), + "hawk invited you to join a chat channel", + "displays the right text for notification" + ); + + assert.ok( + exists(chatInviteNotificationLink.querySelector(".d-icon-link")), + "displays the right icon for the notification" + ); + + assert.strictEqual( + chatInviteNotificationLink.title, + I18n.t("notifications.titles.chat_invitation"), + "has the right title attribute for notification link" + ); + + assert.ok( + chatInviteNotificationLink.href.endsWith( + "/chat/channel/9/site?messageId=174" + ), + "has the right href attribute for notification link" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js b/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js new file mode 100644 index 00000000000..107f62334dd --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/composer-hashtag-autocomplete-test.js @@ -0,0 +1,68 @@ +import { setCaretPosition } from "discourse/lib/utilities"; +import { + acceptance, + exists, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { chatChannelPretender } from "../helpers/chat-pretenders"; +import { fillIn, settled, triggerKeyEvent, visit } from "@ember/test-helpers"; + +acceptance( + "Discourse Chat - Composer hashtag autocompletion", + function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 100, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + chatChannelPretender(server, helper); + server.get("/chat/:id/messages.json", () => + helper.response({ chat_messages: [], meta: {} }) + ); + server.post("/chat/drafts", () => helper.response(500, {})); + server.get("/hashtags/search.json", () => { + return helper.response({ + results: [ + { type: "category", text: "Design", slug: "design", ref: "design" }, + { type: "tag", text: "dev", slug: "dev", ref: "dev" }, + { type: "tag", text: "design", slug: "design", ref: "design::tag" }, + ], + }); + }); + }); + needs.settings({ + chat_enabled: true, + enable_experimental_hashtag_autocomplete: true, + }); + + test("using # in the chat composer shows category and tag autocomplete options", async function (assert) { + await visit("/chat/channel/11/-"); + const composerInput = query(".chat-composer-input"); + await fillIn(".chat-composer-input", "abc #"); + await triggerKeyEvent(".chat-composer-input", "keydown", "#"); + await fillIn(".chat-composer-input", "abc #"); + await setCaretPosition(composerInput, 5); + await triggerKeyEvent(".chat-composer-input", "keyup", "#"); + await triggerKeyEvent(".chat-composer-input", "keydown", "D"); + await fillIn(".chat-composer-input", "abc #d"); + await setCaretPosition(composerInput, 6); + await triggerKeyEvent(".chat-composer-input", "keyup", "D"); + await settled(); + assert.ok( + exists(".hashtag-autocomplete"), + "hashtag autocomplete menu appears" + ); + assert.strictEqual( + queryAll(".hashtag-autocomplete__option").length, + 3, + "all options should be shown" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js b/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js new file mode 100644 index 00000000000..1107f967cd8 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/core-sidebar-test.js @@ -0,0 +1,689 @@ +import { + acceptance, + exists, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { + click, + currentURL, + fillIn, + settled, + triggerKeyEvent, + visit, +} from "@ember/test-helpers"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import I18n from "I18n"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { emojiUnescape } from "discourse/lib/text"; +import User from "discourse/models/user"; + +acceptance("Discourse Chat - Core Sidebar", function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + let directChannels = cloneJSON(directMessageChannels).mapBy("chat_channel"); + directChannels[0].chatable.users = [directChannels[0].chatable.users[0]]; + directChannels[0].current_user_membership.unread_count = 1; + directChannels.push({ + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "sam", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 59, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 76, + title: "@sam", + last_message_sent_at: "2021-06-01T11:15:00.000Z", + current_user_membership: { + unread_count: 0, + muted: true, + following: true, + }, + }); + directChannels.push({ + chatable: { + users: [ + { + id: 1, + username: "", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 77, + title: "@", + last_message_sent_at: "2021-06-01T11:15:00.000Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }); + + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "dev :bug:", + chatable_type: "Category", + chatable: { slug: "dev", read_restricted: true }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 0, + unread_mentions: 0, + }, + }, + { + id: 2, + title: "general", + chatable_type: "Category", + chatable: { slug: "general" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 0, + }, + }, + { + id: 3, + title: "random", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + muted: true, + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 4, + title: "", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + ], + direct_message_channels: directChannels, + }); + }); + + server.get("/chat/1/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/u/search/users", () => { + return helper.response({ + users: [ + { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/41988e/{size}.png", + }, + ], + }); + }); + + server.get("/chat/75/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/chat/direct_messages.json", () => { + return helper.response({ + chat_channel: { + id: 75, + title: "hawk", + chatable_type: "DirectMessageChannel", + last_message_sent_at: "2021-07-20T08:14:16.950Z", + chatable: { + users: [{ username: "hawk" }], + }, + }, + }); + }); + }); + + needs.hooks.beforeEach(function () { + withPluginApi("1.3.0", (api) => { + api.addUsernameSelectorDecorator((username) => { + if (username === "hawk") { + return `${emojiUnescape( + ":desert_island:" + )}`; + } + }); + }); + }); + + test("Public channels section", async function (assert) { + await visit("/"); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-header-text" + ).textContent.trim(), + I18n.t("chat.chat_channels"), + "displays correct channels section title" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "dev channel section link displays hash icon prefix" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-prefix svg.prefix-badge.d-icon-lock" + ), + "dev channel section link displays lock badge for restricted channel" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .emoji" + ), + "unescapes emoji in channel title in the link" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug" + ).textContent.trim(), + "dev", + "dev channel section link displays channel title in the link" + ); + + assert.ok( + query( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug" + ).href.endsWith("/chat/channel/1/dev-bug"), + "dev channel section link has the right href attribute" + ); + + assert.notOk( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-dev-bug .sidebar-section-link-suffix" + ), + "does not display new messages indicator" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "general channel section link displays hash icon prefix" + ); + + assert.notOk( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-prefix svg.prefix-badge" + ), + "general channel section link does not display lock badge for public channel" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-general" + ).textContent.trim(), + "general", + "general channel section link displays channel title in the link" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-general .sidebar-section-link-suffix.unread" + ), + "general section link has new messages indicator" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag" + ), + "random channel section link displays hash icon prefix" + ); + + assert.strictEqual( + query( + ".sidebar-section-chat-channels .sidebar-section-link-random" + ).textContent.trim(), + "random", + "random channel section link displays channel title in the link" + ); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random .sidebar-section-link-suffix.urgent" + ), + "random section link has new messages mention indicator" + ); + }); + + test("sidebar section link when direct message channel is muted by user", async function (assert) { + await visit("/"); + + assert.ok( + exists( + ".sidebar-section-chat-dms .sidebar-section-link-sam .sidebar-section-link-content-muted" + ), + "muted direct chat channel has right CSS class configured" + ); + }); + + test("sidebar section link when public channel is muted by user", async function (assert) { + await visit("/"); + + assert.ok( + exists( + ".sidebar-section-chat-channels .sidebar-section-link-random .sidebar-section-link-content-muted" + ), + "muted random chat channel has right CSS class configured" + ); + }); + + test("Direct messages section", async function (assert) { + const chatService = this.container.lookup("service:chat"); + chatService.directMessagesLimit = 2; + await visit("/"); + + assert.strictEqual( + query( + ".sidebar-section-chat-dms .sidebar-section-header-text" + ).textContent.trim(), + I18n.t("chat.direct_messages.title"), + "displays correct direct messages section title" + ); + + let directLinks = queryAll( + ".sidebar-section-chat-dms a.sidebar-section-link" + ); + + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-prefix img") + .classList.contains("prefix-image"), + true, + "displays avatar in prefix when two participants" + ); + + assert.strictEqual( + directLinks[0].textContent.trim(), + "hawk", + "displays user name in a link" + ); + + assert.ok( + directLinks[0].querySelector( + ".sidebar-section-link-content-text .on-holiday img" + ), + "displays flair when user is on holiday" + ); + + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-suffix") + .classList.contains("urgent"), + true, + "displays new messages indicator" + ); + + assert.strictEqual( + directLinks[1] + .querySelector("span.sidebar-section-link-prefix") + .classList.contains("text"), + true, + "displays text in prefix when more than two participants" + ); + + assert.strictEqual( + directLinks[1] + .querySelector(".sidebar-section-link-content-text") + .textContent.trim(), + "eviltrout, markvanlan", + "displays all participants name in a link" + ); + + assert.ok( + !directLinks[1].querySelector(".sidebar-section-link-suffix"), + "does not display new messages indicator" + ); + User.current().chat_channel_tracking_state[76].set("unread_count", 99); + chatService.reSortDirectMessageChannels(); + chatService.appEvents.trigger("chat:user-tracking-state-changed"); + await settled(); + + directLinks = queryAll(".sidebar-section-chat-dms a.sidebar-section-link"); + assert.strictEqual( + directLinks[0] + .querySelector(".sidebar-section-link-content-text") + .textContent.trim(), + "eviltrout, markvanlan", + "reorders private messages" + ); + + assert.equal( + directLinks.length, + 2, + "limits number of displayed direct messages" + ); + }); + + test("Plugin sidebar is hidden", async function (assert) { + await visit("/chat/channel/1/dev"); + assert.notOk(exists(".full-page-chat .channels-list")); + }); + + test("Open a new direct conversation", async function (assert) { + await visit("/"); + + await click(".sidebar-section-chat-dms .sidebar-section-header-button"); + assert.ok(exists(".direct-message-creator")); + + await fillIn(".filter-usernames", "hawk"); + await triggerKeyEvent(".filter-usernames", "keydown", "Enter"); + assert.strictEqual(currentURL(), "/chat/draft-channel"); + }); + + test("Escapes public channel titles", async function (assert) { + await visit("/"); + + const evilChannel = query( + ".sidebar-section-chat-channels .sidebar-section-link-wrapper .sidebar-section-link" + ); + + assert.strictEqual(evilChannel.title, "<script>evil</script>"); + + assert.ok( + evilChannel.className.includes( + "sidebar-section-link-ltscriptgtevilltscriptgt" + ) + ); + + assert.strictEqual( + evilChannel + .querySelector(".sidebar-section-link-content-text") + .innerHTML.trim(), + "<script>evil</script>" + ); + }); + + test("Escapes dm channel titles", async function (assert) { + await visit("/"); + + const evilChannel = queryAll( + ".sidebar-section-chat-dms .sidebar-section-link-wrapper .sidebar-section-link" + )[3]; + + assert.strictEqual(evilChannel.title, "@<script>sam</script>"); + + assert.ok( + evilChannel.className.includes( + "sidebar-section-link-ltscriptgtsamltscriptgt" + ) + ); + + assert.strictEqual( + evilChannel + .querySelector(".sidebar-section-link-content-text") + .innerHTML.trim(), + "&lt;script&gt;sam&lt;/script&gt;" + ); + }); +}); + +acceptance("Discourse Chat - Plugin Sidebar", function (needs) { + needs.user({ has_chat_enabled: true }); + + needs.settings({ + chat_enabled: true, + enable_sidebar: false, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "dev :bug:", + chatable_type: "Category", + chatable: { slug: "dev", read_restricted: true }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 2, + title: "general", + chatable_type: "Category", + chatable: { slug: "general" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + { + id: 3, + title: "random", + chatable_type: "Category", + chatable: { slug: "random" }, + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 1, + unread_mentions: 1, + }, + }, + ], + direct_message_channels: [], + }); + }); + + server.get("/chat/1/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + }); + + test("Plugin sidebar is visible", async function (assert) { + await visit("/chat/channel/1/dev"); + assert.ok(exists(".full-page-chat .channels-list")); + }); +}); + +acceptance( + "Discourse Chat - Core Sidebar - no joinable public channels, staff", + function (needs) { + needs.user({ has_chat_enabled: true, has_joinable_public_channels: false }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Chat channels section visibility", async function (assert) { + await visit("/"); + + assert.ok( + exists(".sidebar-section-chat-channels"), + "it shows the section for staff" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - no joinable public channels, regular user", + function (needs) { + needs.user({ + has_chat_enabled: true, + has_joinable_public_channels: false, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Chat channels section visibility", async function (assert) { + await visit("/"); + + assert.notOk( + exists(".sidebar-section-chat-channels"), + "it doesn’t show the section for regular user" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - regular user with no direct message channels who cannot send direct messages", + function (needs) { + needs.user({ + has_chat_enabled: true, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + direct_message_enabled_groups: "13", // trust_level_3 auto group ID; + }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: [], + }); + }); + }); + + test("Direct message channels section visibility", async function (assert) { + await visit("/"); + + assert.notOk( + exists(".sidebar-section-chat-dms"), + "it doesn’t show the section for regular user" + ); + }); + } +); + +acceptance( + "Discourse Chat - Core Sidebar - regular user with existing direct message channels who cannot send direct messages", + function (needs) { + needs.user({ + has_chat_enabled: true, + moderator: false, + admin: false, + }); + + needs.settings({ + chat_enabled: true, + enable_experimental_sidebar_hamburger: true, + enable_sidebar: true, + direct_message_enabled_groups: "13", // trust_level_3 auto group ID; + }); + + needs.pretender((server, helper) => { + let directChannels = cloneJSON(directMessageChannels).mapBy( + "chat_channel" + ); + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [], + direct_message_channels: directChannels, + }); + }); + }); + + test("Direct message channels section visibility", async function (assert) { + await visit("/"); + + assert.ok( + exists(".sidebar-section-chat-dms"), + "it does show the section for a regular user" + ); + + assert.notOk( + exists(".sidebar-section-chat-dms .sidebar-section-header-button"), + "user cannot see the create DM channel button" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/acceptance/create-channel-test.js b/plugins/chat/test/javascripts/acceptance/create-channel-test.js new file mode 100644 index 00000000000..a478f2225c9 --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/create-channel-test.js @@ -0,0 +1,179 @@ +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { click, visit } from "@ember/test-helpers"; +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Create channel modal", function (needs) { + const maliciousText = '"'; + + needs.user({ + username: "tomtom", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + + needs.settings({ + chat_enabled: true, + }); + + const catsCategory = { + id: 1, + name: "Cats", + slug: "cats", + permission: 1, + }; + + needs.site({ + categories: [ + catsCategory, + { + id: 2, + name: maliciousText, + slug: maliciousText, + permission: 1, + }, + { + id: 3, + name: "Kittens", + slug: "kittens", + permission: 1, + parentCategory: catsCategory, + }, + ], + }); + + needs.pretender((server, helper) => { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response({ + meta: { can_chat: true, user_silenced: false }, + chat_messages: [], + }) + ); + + server.get("/chat/chat_channels.json", () => + helper.response({ + public_channels: [], + direct_message_channels: [], + }) + ); + + server.get("/chat/chat_channels/:chatChannelId", () => + helper.response({ id: 1, title: "something" }) + ); + + server.get("/chat/api/chat_channels.json", () => helper.response([])); + + server.get( + "/chat/api/category-chatables/:categoryId/permissions.json", + (request) => { + if (request.params.categoryId === "2") { + return helper.response({ + allowed_groups: ["@"], + members_count: 2, + private: true, + }); + } else { + return helper.response({ + allowed_groups: ["@awesomeGroup"], + members_count: 2, + private: true, + }); + } + } + ); + }); + + test("links to categories and selected category's security settings", async function (assert) { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Cats"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "security settings" + ); + assert.ok( + query(".create-channel-hint a").href.includes("/c/cats/edit/security") + ); + }); + + test("links to selected category's security settings works with nested subcategories", async function (assert) { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Kittens"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "security settings" + ); + assert.ok( + query(".create-channel-hint a").href.includes( + "/c/cats/kittens/edit/security" + ) + ); + }); + + test("includes group names in the hint", async (assert) => { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + let categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByName("Kittens"); + + assert.strictEqual( + query(".create-channel-hint").innerHTML.trim(), + 'Users in @awesomeGroup will have access to this channel per the security settings' + ); + }); + + test("escapes group name/category slug in the hint", async (assert) => { + await visit("/chat/browse"); + await click(".new-channel-btn"); + + assert.strictEqual( + query(".create-channel-hint a").innerText, + "category security settings" + ); + assert.ok(query(".create-channel-hint a").href.includes("/categories")); + + const categories = selectKit(".create-channel-modal .category-chooser"); + await categories.expand(); + await categories.selectRowByValue(2); + + assert.strictEqual( + query(".create-channel-hint").innerHTML.trim(), + 'Users in @<script>evilgroup</script> will have access to this channel per the security settings' + ); + assert.ok( + query(".create-channel-hint a").href.includes( + "c/%22%3Cscript%3E%3C/script%3E/edit/security" + ) + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js b/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js new file mode 100644 index 00000000000..7510ae1450f --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/delete-chat-channel-modal-test.js @@ -0,0 +1,42 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import fabricators from "../helpers/fabricators"; + +acceptance("Discourse Chat - delete chat channel modal", function (needs) { + needs.user({ has_chat_enabled: true, can_chat: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [fabricators.chatChannel({ id: 2 })], + direct_message_channels: [], + }); + }); + + server.get("/chat/chat_channels/:id", (request) => { + return helper.response( + fabricators.chatChannel({ id: request.params.id }) + ); + }); + + server.get("/chat/:id/messages.json", () => { + return helper.response({ meta: {}, chat_messages: [] }); + }); + + server.delete("/chat/chat_channels/:id.json", () => { + return helper.response({}); + }); + }); + + test("Redirection after deleting a channel", async function (assert) { + await visit("chat/channel/1/my-category-title/info/settings"); + await click(".delete-btn"); + await fillIn("#channel-delete-confirm-name", "My category title"); + await click("#chat-confirm-delete-channel"); + + assert.equal(currentURL(), "/chat/channel/2/my-category-title"); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js b/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js new file mode 100644 index 00000000000..cac3ec62bcd --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/mobile-chat-test.js @@ -0,0 +1,57 @@ +import { + acceptance, + exists, + loggedInUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, visit } from "@ember/test-helpers"; +import { + chatChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; + +acceptance("Discourse Chat - Mobile test", function (needs) { + needs.user({ can_chat: true, has_chat_enabled: true }); + + needs.mobileView(); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/:id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.get("/u/search/users", () => { + return helper.response([]); + }); + }); + + needs.settings({ + chat_enabled: true, + }); + + test("Chat index route shows channels list", async function (assert) { + await visit("/latest"); + await click(".header-dropdown-toggle.open-chat"); + assert.equal(currentURL(), "/chat"); + assert.ok(exists(".channels-list")); + await click(".chat-channel-row.chat-channel-7"); + assert.notOk(exists(".chat-full-screen-button")); + }); + + test("Chat new personal chat buttons", async function (assert) { + await visit("/chat"); + await click(".new-dm.btn-floating"); + assert.strictEqual( + currentURL(), + "/chat/draft-channel", + "Clicking the floating + button opens the new chat screen" + ); + + await click(".chat-draft-header__btn"); + assert.strictEqual( + currentURL(), + "/chat", + "Clicking the left arrow button returns to the channels list" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/nagivation-scenarios-test.js b/plugins/chat/test/javascripts/acceptance/nagivation-scenarios-test.js new file mode 100644 index 00000000000..5499e42239e --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/nagivation-scenarios-test.js @@ -0,0 +1,39 @@ +import { + acceptance, + loggedInUser, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, visit } from "@ember/test-helpers"; +import { generateChatView } from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; +import fabricators from "../helpers/fabricators"; + +acceptance("Discourse Chat - Navigation scenarios", function (needs) { + needs.user({ can_chat: true, has_chat_enabled: true }); + + needs.settings({ chat_enabled: true }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => + helper.response({ public_channels: [fabricators.chatChannel()] }) + ); + + server.get("/chat/:chat_channel_id/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + }); + + test("Switching off full screen brings you back to previous route", async function (assert) { + this.container.lookup("service:full-page-chat").exit(); + await visit("/t/-/280"); + await visit("/chat"); + + assert.equal(currentURL(), "/chat/channel/1/my-category-title"); + + await click(".chat-full-screen-button"); + + assert.ok( + currentURL().startsWith("/t/internationalization-localization/280"), + "it redirects back to the visited topic before going full screen" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js b/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js new file mode 100644 index 00000000000..cfa0bb3b6ce --- /dev/null +++ b/plugins/chat/test/javascripts/acceptance/user-card-chat-test.js @@ -0,0 +1,119 @@ +import userFixtures from "discourse/tests/fixtures/user-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import { + acceptance, + exists, + loggedInUser, + query, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, settled, visit } from "@ember/test-helpers"; +import { + chatChannels, + directMessageChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; +import { test } from "qunit"; + +acceptance("Discourse Chat - User card test", function (needs) { + needs.user({ + admin: false, + moderator: false, + username: "eviltrout", + id: 1, + can_chat: true, + has_chat_enabled: true, + }); + needs.pretender((server, helper) => { + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.get("/chat/chat_channels.json", () => helper.response(chatChannels)); + server.get("/chat/chat_channels/:channelId.json", () => + helper.response(helper.response(directMessageChannels[0])) + ); + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(loggedInUser())) + ); + server.post("/chat/direct_messages/create.json", () => { + return helper.response({ + chat_channel: { + chat_channels: [], + chatable: { + users: [ + { + username: "hawk", + id: 2, + name: "hawk", + avatar_template: + "/letter_avatar_proxy/v3/letter/t/41988e/{size}.png", + }, + ], + }, + chatable_id: 16, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 75, + title: "@hawk", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + last_read_message_id: null, + unread_count: 0, + unread_mentions: 0, + }, + }, + }); + }); + let cardResponse = cloneJSON(userFixtures["/u/charlie/card.json"]); + cardResponse.user.can_chat_user = true; + server.get("/u/hawk/card.json", () => helper.response(cardResponse)); + }); + needs.settings({ + chat_enabled: true, + }); + + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "appEvents", { + get: () => this.container.lookup("service:appEvents"), + }); + }); + + test("user card has chat button that opens the correct channel", async function (assert) { + this.chatService.set("sidebarActive", false); + this.chatService.set("chatWindowFullPage", false); + await visit("/latest"); + this.appEvents.trigger("chat:toggle-open"); + await settled(); + + await click(".topic-chat-drawer-header__return-to-channels-btn"); + await click(".chat-channel-row.chat-channel-9"); + await click("[data-user-card='hawk']"); + assert.ok(exists(".user-card-chat-btn")); + + await click(".user-card-chat-btn"); + assert.ok(visible(".topic-chat-float-container"), "chat float is open"); + assert.ok(query(".topic-chat-container").classList.contains("channel-75")); + }); +}); + +acceptance( + "Discourse Chat - Anon user viewing user card test", + function (needs) { + needs.settings({ + chat_enabled: true, + }); + + test("user card has no chat button", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click('a[data-user-card="charlie"]'); + + assert.notOk( + exists(".user-card-chat-btn"), + "anon user should not be able to chat with anyone via the user card" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/chat-fixtures.js b/plugins/chat/test/javascripts/chat-fixtures.js new file mode 100644 index 00000000000..3206fd7962f --- /dev/null +++ b/plugins/chat/test/javascripts/chat-fixtures.js @@ -0,0 +1,334 @@ +import { deepMerge } from "discourse-common/lib/object"; + +export const messageContents = ["Hello world", "What up", "heyo!"]; + +export const directMessageChannels = [ + { + chat_channel: { + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 2, + username: "hawk", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 58, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 75, + title: "@hawk", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + last_message_sent_at: "2021-07-20T08:14:16.950Z", + }, + }, + { + chat_channel: { + chatable: { + users: [ + { + id: 1, + username: "markvanlan", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + { + id: 3, + username: "eviltrout", + avatar_template: + "/letter_avatar_proxy/v4/letter/t/f9ae1b/{size}.png", + }, + ], + }, + chatable_id: 59, + chatable_type: "DirectMessageChannel", + chatable_url: null, + id: 76, + title: "@eviltrout, @markvanlan", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + last_message_sent_at: "2021-07-05T12:04:00.850Z", + }, + }, +]; + +const chatables = { + 1: { + id: 1, + name: "Bug", + color: "0088CC", + text_color: "FFFFFF", + slug: "bug", + }, + 8: { + id: 8, + name: "Public category", + slug: "public_category", + posts_count: 1, + }, + 12: { + id: 12, + name: "Another category", + slug: "another-category", + posts_count: 100, + }, +}; + +export const chatChannels = { + public_channels: [ + { + id: 9, + chatable_id: 1, + chatable_type: "Category", + chatable_url: "/c/bug/1", + title: "Site", + status: "open", + chatable: chatables[1], + last_message_sent_at: "2021-07-24T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 7, + chatable_id: 1, + chatable_type: "Category", + chatable_url: "/c/bug/1", + title: "Bug", + status: "open", + chatable: chatables[1], + last_message_sent_at: "2021-07-15T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 4, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category", + status: "open", + chatable: chatables[8], + last_message_sent_at: "2021-07-14T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 5, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (read-only)", + status: "read_only", + chatable: chatables[8], + last_message_sent_at: "2021-07-10T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 6, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (closed)", + status: "closed", + chatable: chatables[8], + last_message_sent_at: "2021-07-21T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 10, + chatable_id: 8, + chatable_type: "Category", + chatable_url: "/c/public-category/8", + title: "Public category (archived)", + status: "archived", + chatable: chatables[8], + last_message_sent_at: "2021-07-25T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + { + id: 11, + chatable_id: 12, + chatable_type: "Category", + chatable_url: "/c/another-category/12", + title: "Another Category", + status: "open", + chatable: chatables[12], + last_message_sent_at: "2021-07-02T08:14:16.950Z", + current_user_membership: { + unread_count: 0, + muted: false, + following: true, + }, + }, + ], + direct_message_channels: directMessageChannels.mapBy("chat_channel"), +}; + +const message0 = { + id: 174, + message: messageContents[0], + cooked: messageContents[0], + excerpt: messageContents[0], + created_at: "2021-07-20T08:14:16.950Z", + flag_count: 0, + user: { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + available_flags: ["spam"], +}; + +const message1 = { + id: 175, + message: messageContents[1], + cooked: messageContents[1], + excerpt: messageContents[1], + created_at: "2021-07-20T08:14:22.043Z", + flag_count: 0, + user: { + id: 2, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + in_reply_to: message0, + uploads: [ + { + extension: "pdf", + filesize: 861550, + height: null, + human_filesize: "841 KB", + id: 38, + original_filename: "Chat message PDF!", + retain_hours: null, + short_path: "/uploads/short-url/vYozObYao54I6G3x8wvOf73epfX.pdf", + short_url: "upload://vYozObYao54I6G3x8wvOf73epfX.pdf", + thumbnail_height: null, + thumbnail_width: null, + url: "/images/avatar.png", + width: null, + }, + ], + available_flags: ["spam"], +}; + +const message2 = { + id: 176, + message: messageContents[2], + cooked: messageContents[2], + excerpt: messageContents[2], + created_at: "2021-07-20T08:14:25.043Z", + flag_count: 0, + user: { + id: 2, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + in_reply_to: message0, + uploads: [ + { + extension: "png", + filesize: 50419, + height: 393, + human_filesize: "49.2 KB", + id: 37, + original_filename: "image.png", + retain_hours: null, + short_path: "/uploads/short-url/2LbadI7uOM7JsXyVoc12dHUjJYo.png", + short_url: "upload://2LbadI7uOM7JsXyVoc12dHUjJYo.png", + thumbnail_height: 224, + thumbnail_width: 689, + url: "/images/avatar.png", + width: 1209, + }, + ], + reactions: { + heart: { + count: 1, + reacted: false, + users: [{ id: 99, username: "im-penar" }], + }, + kiwi_fruit: { + count: 2, + reacted: true, + users: [{ id: 99, username: "im-penar" }], + }, + tada: { + count: 1, + reacted: true, + users: [], + }, + }, + available_flags: ["spam"], +}; + +const message3 = { + id: 177, + message: "gg @osama @mark @here", + cooked: + '

gg @osama @mark @here

', + excerpt: + '

gg @osama @mark @here

', + created_at: "2021-07-22T08:14:16.950Z", + flag_count: 0, + user: { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", + }, + available_flags: ["spam"], +}; + +export function generateChatView(loggedInUser, metaOverrides = {}) { + const metaDefaults = { + can_flag: true, + user_silenced: false, + can_moderate: loggedInUser.staff, + can_delete_self: true, + can_delete_others: loggedInUser.staff, + }; + return { + meta: deepMerge(metaDefaults, metaOverrides), + chat_messages: [message0, message1, message2, message3], + }; +} diff --git a/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js new file mode 100644 index 00000000000..60daa3adb9b --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-about-view-test.js @@ -0,0 +1,149 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { render, settled } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import I18n from "I18n"; + +module( + "Discourse Chat | Component | chat-channel-about-view | admin user", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.set("description", "foo"); + this.currentUser.set("admin", true); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("chatable name", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + }); + + test("chatable description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + + this.channel.set("description", null); + await settled(); + + assert.equal( + query(".channel-info-about-view__description__helper-text").innerText, + I18n.t("chat.channel_edit_description_modal.description") + ); + }); + + test("edit title", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".edit-title-btn")); + }); + + test("edit description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".edit-description-btn")); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-join")); + }); + + test("leave", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-about-view | regular user", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.set("description", "foo"); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("escapes channel title", async function (assert) { + this.channel.set("title", "
evil
"); + + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("chatable name", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + }); + + test("chatable description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.equal( + query(".category-name").innerText, + this.channel.chatable.name + ); + + this.channel.set("description", null); + await settled(); + + assert.notOk(exists(".channel-info-about-view__description")); + }); + + test("edit title", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".edit-title-btn")); + }); + + test("edit description", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.notOk(exists(".edit-description-btn")); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-join")); + }); + + test("leave", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-about-view channel=channel}}`); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js new file mode 100644 index 00000000000..405d2c7aa78 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-archive-modal-inner-test.js @@ -0,0 +1,31 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-archive-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel({})); + }); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set("channel.title", ``); + + await render( + hbs`{{chat-channel-archive-modal-inner chatChannel=channel}}` + ); + + assert.ok( + query(".chat-channel-archive-modal-instructions").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-card-test.js b/plugins/chat/test/javascripts/components/chat-channel-card-test.js new file mode 100644 index 00000000000..15068e6a4b7 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-card-test.js @@ -0,0 +1,130 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import I18n from "I18n"; + +module("Discourse Chat | Component | chat-channel-card", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel()); + this.channel.set( + "description", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + ); + }); + + test("escapes channel title", async function (assert) { + this.channel.set("title", "
evil
"); + + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("escapes channel description", async function (assert) { + this.channel.set("description", "
evil
"); + + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".xss")); + }); + + test("Closed channel", async function (assert) { + this.channel.set("status", "closed"); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card.-closed")); + }); + + test("Archived channel", async function (assert) { + this.channel.set("status", "archived"); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card.-archived")); + }); + + test("Muted channel", async function (assert) { + this.channel.current_user_membership.set("muted", true); + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__tag.-muted").textContent.trim(), + I18n.t("chat.muted") + ); + }); + + test("Joined channel", async function (assert) { + this.channel.current_user_membership.set("following", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__tag.-joined").textContent.trim(), + I18n.t("chat.joined") + ); + + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }); + + test("Joinable channel", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card__join-btn")); + }); + + test("Memberships count", async function (assert) { + this.channel.set("memberships_count", 4); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__members").textContent.trim(), + I18n.t("chat.channel.memberships_count", { count: 4 }) + ); + }); + + test("No description", async function (assert) { + this.channel.set("description", null); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.notOk(exists(".chat-channel-card__description")); + }); + + test("Description", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__description").textContent.trim(), + this.channel.description + ); + }); + + test("Name", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.equal( + query(".chat-channel-card__name").innerText.trim(), + this.channel.title + ); + }); + + test("Settings button", async function (assert) { + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".chat-channel-card__setting")); + }); + + test("Read restricted chatable", async function (assert) { + this.channel.set("chatable.read_restricted", true); + await render(hbs`{{chat-channel-card channel=channel}}`); + + assert.ok(exists(".d-icon-lock")); + assert.equal( + query(".chat-channel-card").style.borderLeftColor, + "rgb(213, 99, 83)" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js new file mode 100644 index 00000000000..c860fd8b48a --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-delete-modal-inner-test.js @@ -0,0 +1,31 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-delete-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("channel", fabricators.chatChannel({})); + }); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set("channel.title", ``); + + await render( + hbs`{{chat-channel-delete-modal-inner chatChannel=channel}}` + ); + + assert.ok( + query(".chat-channel-delete-modal-instructions").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js b/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js new file mode 100644 index 00000000000..65828f16ec9 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-leave-btn-test.js @@ -0,0 +1,81 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import pretender from "discourse/tests/helpers/create-pretender"; +import I18n from "I18n"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-leave-btn", function (hooks) { + setupRenderingTest(hooks); + + componentTest("accepts an optional onLeaveChannel callback", { + template: hbs`{{chat-channel-leave-btn channel=channel onLeaveChannel=onLeaveChannel}}`, + + beforeEach() { + this.set("foo", 1); + this.set("onLeaveChannel", () => this.set("foo", 2)); + this.set("channel", { + id: 1, + chatable_type: "DirectMessageChannel", + chatable: { + users: [{ id: 1 }], + }, + }); + }, + + async test(assert) { + pretender.post("/chat/chat_channels/:chatChannelId/unfollow", () => { + return [200, { current_user_membership: { following: false } }, {}]; + }); + assert.equal(this.foo, 1); + + await click(".chat-channel-leave-btn"); + + assert.equal(this.foo, 2); + }, + }); + + componentTest("has a specific title for direct message channel", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.set("channel", { chatable_type: "DirectMessageChannel" }); + }, + + async test(assert) { + const btn = query(".chat-channel-leave-btn"); + + assert.equal(btn.title, I18n.t("chat.direct_messages.leave")); + }, + }); + + componentTest("has a specific title for message channel", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.set("channel", { chatable_type: "Topic" }); + }, + + async test(assert) { + const btn = query(".chat-channel-leave-btn"); + + assert.equal(btn.title, I18n.t("chat.leave")); + }, + }); + + componentTest("is not visible on mobile", { + template: hbs`{{chat-channel-leave-btn channel=channel}}`, + + beforeEach() { + this.site.mobileView = true; + this.set("channel", { chatable_type: "Topic" }); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-leave-btn")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js new file mode 100644 index 00000000000..d1953870e11 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-members-view-test.js @@ -0,0 +1,140 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import I18n from "I18n"; +import { Promise } from "rsvp"; +import { fillIn, triggerEvent } from "@ember/test-helpers"; +import { module } from "qunit"; + +function fetchMembersHandler(channelId, params = {}) { + if (params.offset === 50) { + return Promise.resolve([{ user: { id: 3, username: "clara" } }]); + } + + if (params.offset === 100) { + return Promise.resolve([]); + } + + if (!params.username) { + return Promise.resolve([ + { user: { id: 1, username: "jojo" } }, + { user: { id: 2, username: "bob" } }, + ]); + } + + if (params.username === "jojo") { + return Promise.resolve([{ user: { id: 1, username: "jojo" } }]); + } else { + return Promise.resolve([]); + } +} + +function setupState(context) { + context.set("fetchMembersHandler", fetchMembersHandler); + context.set("channel", fabricators.chatChannel()); + context.channel.set("memberships_count", 2); +} + +module( + "Discourse Chat | Component | chat-channel-members-view", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("no filter", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + this.set("fetchMembersHandler", fetchMembersHandler); + this.set("channel", fabricators.chatChannel()); + this.channel.set("memberships_count", 2); + }, + + async test(assert) { + assert.ok( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.ok( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("filter", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + setupState(this); + }, + + async test(assert) { + await fillIn(".channel-members-view__search-input", "jojo"); + + assert.ok( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("filter with no results", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + setupState(this); + }, + + async test(assert) { + await fillIn(".channel-members-view__search-input", "cat"); + + assert.equal( + query(".channel-members-view__list").innerText.trim(), + I18n.t("chat.channel.no_memberships_found") + ); + + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='jojo']") + ); + assert.notOk( + exists(".channel-members-view__list-item[data-user-card='bob']") + ); + }, + }); + + componentTest("loading more", { + template: hbs`{{chat-channel-members-view channel=channel fetchMembersHandler=fetchMembersHandler}}`, + + beforeEach() { + this.set("fetchMembersHandler", fetchMembersHandler); + this.set("channel", fabricators.chatChannel()); + this.channel.set("memberships_count", 3); + }, + + async test(assert) { + await triggerEvent(".channel-members-view__list", "scroll"); + + ["jojo", "bob", "clara"].forEach((username) => { + assert.ok( + exists( + `.channel-members-view__list-item[data-user-card='${username}']` + ) + ); + }); + + await triggerEvent(".channel-members-view__list", "scroll"); + + ["jojo", "bob", "clara"].forEach((username) => { + assert.ok( + exists( + `.channel-members-view__list-item[data-user-card='${username}']` + ) + ); + }); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js b/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js new file mode 100644 index 00000000000..e4b693970ea --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-preview-card-test.js @@ -0,0 +1,95 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import fabricators from "../helpers/fabricators"; + +module( + "Discourse Chat | Component | chat-channel-preview-card", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set( + "channel", + fabricators.chatChannel({ chatable_type: "Category" }) + ); + this.channel.setProperties({ + description: "Important stuff is announced here.", + title: "announcements", + }); + this.currentUser.set("has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + }); + + test("channel title", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.equal( + query(".chat-channel-title__name").innerText, + this.channel.title, + "it shows the channel title" + ); + + assert.ok( + exists(query(".chat-channel-title__category-badge")), + "it shows the category hashtag badge" + ); + }); + + test("channel description", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.equal( + query(".chat-channel-preview-card__description").innerText, + this.channel.description, + "the channel description is shown" + ); + }); + + test("no channel description", async function (assert) { + this.channel.set("description", null); + + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.notOk( + exists(".chat-channel-preview-card__description"), + "no line is left for the channel description if there is none" + ); + + assert.ok( + exists(".chat-channel-preview-card.-no-description"), + "it adds a modifier class for styling" + ); + }); + + test("join", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.ok( + exists(".toggle-channel-membership-button.-join"), + "it shows the join channel button" + ); + }); + + test("browse all", async function (assert) { + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.ok( + exists(".chat-channel-preview-card__browse-all"), + "it shows a link to browse all channels" + ); + }); + + test("closed channel", async function (assert) { + this.channel.set("status", "closed"); + await render(hbs`{{chat-channel-preview-card channel=channel}}`); + + assert.notOk( + exists(".chat-channel-preview-card__join-channel-btn"), + "it does not show the join channel button" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-row-test.js b/plugins/chat/test/javascripts/components/chat-channel-row-test.js new file mode 100644 index 00000000000..ae1c968f394 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-row-test.js @@ -0,0 +1,154 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { click, triggerKeyEvent } from "@ember/test-helpers"; +import fabricators from "../helpers/fabricators"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-row", function (hooks) { + setupRenderingTest(hooks); + + componentTest("with leaveButton", { + template: hbs`{{chat-channel-row channel=channel options=(hash leaveButton=true)}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + current_user_membership: { following: true }, + }) + ); + }, + + async test(assert) { + assert.ok(exists(".toggle-channel-membership-button.-leave")); + }, + }); + + componentTest("without leaveButton", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-leave-btn")); + }, + }); + + componentTest("receives click", { + template: hbs`{{chat-channel-row switchChannel=switchChannel channel=channel}}`, + + beforeEach() { + this.set("switchedChannel", null); + this.set("channel", fabricators.chatChannel()); + this.set("switchChannel", (channel) => + this.set("switchedChannel", channel.id) + ); + }, + + async test(assert) { + await click(".chat-channel-row"); + + assert.strictEqual(this.switchedChannel, this.channel.id); + }, + }); + + componentTest("receives Enter keyup", { + template: hbs`{{chat-channel-row switchChannel=switchChannel channel=channel}}`, + + beforeEach() { + this.set("switchedChannel", null); + this.set("channel", fabricators.chatChannel()); + this.set("switchChannel", (channel) => + this.set("switchedChannel", channel.id) + ); + }, + + async test(assert) { + await triggerKeyEvent(".chat-channel-row", "keyup", "Enter"); + + assert.strictEqual(this.switchedChannel, this.channel.id); + }, + }); + + componentTest( + "a row is active when the associated channel is active and visible", + { + template: hbs`{{chat-channel-row switchChannel=switchChannel channel=channel chat=chat router=router}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + this.set("chat", { activeChannel: this.channel }); + this.set("router", { currentRouteName: "chat.channel" }); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-row.active")); + + this.set("router.currentRouteName", "chat.browse"); + + assert.notOk(exists(".chat-channel-row.active")); + + this.set("router.currentRouteName", "chat.channel"); + this.set("chat.activeChannel", null); + + assert.notOk(exists(".chat-channel-row.active")); + }, + } + ); + + componentTest("can receive a tab event", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-row[tabindex=0]")); + }, + }); + + componentTest("shows user status on the direct message channel", { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + const status = { description: "Off to dentist", emoji: "tooth" }; + const channel = fabricators.directMessageChatChannel(); + channel.chatable.users[0].status = status; + this.set("channel", channel); + }, + + async test(assert) { + assert.ok(exists(".user-status-message")); + }, + }); + + componentTest( + "doesn't show user status on a direct message channel with multiple users", + { + template: hbs`{{chat-channel-row channel=channel}}`, + + beforeEach() { + const status = { description: "Off to dentist", emoji: "tooth" }; + const channel = fabricators.directMessageChatChannel(); + channel.chatable.users[0].status = status; + channel.chatable.users.push({ + id: 2, + username: "bill", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/31188e/{size}.png", + }); + this.set("channel", channel); + }, + + async test(assert) { + assert.notOk(exists(".user-status-message")); + }, + } + ); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js new file mode 100644 index 00000000000..c869b71f212 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-settings-view-test.js @@ -0,0 +1,270 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { set } from "@ember/object"; +import { module } from "qunit"; + +function membershipFixture(id, options = {}) { + options = Object.assign({}, options, { muted: false, following: true }); + + return { + following: options.following, + muted: options.muted, + desktop_notification_level: "mention", + mobile_notification_level: "mention", + chat_channel_id: id, + chatable_type: "Category", + user_count: 2, + }; +} + +module( + "Discourse Chat | Component | chat-channel-settings-view | Public channel - regular user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("saving desktop notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__desktop-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("saving mobile notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__mobile-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("muted", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id, { muted: true }), + ]; + } + ); + + const sk = selectKit(".channel-settings-view__muted-selector"); + await sk.expand(); + await sk.selectRowByName("Off"); + + assert.equal(sk.header().value(), "false"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-settings-view | Direct Message channel - regular user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("saving desktop notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__desktop-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("saving mobile notifications", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id), + ]; + } + ); + + const sk = selectKit( + ".channel-settings-view__mobile-notification-level-selector" + ); + await sk.expand(); + await sk.selectRowByValue("mention"); + + assert.equal(sk.header().value(), "mention"); + }, + }); + + componentTest("muted", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }) + ); + }, + + async test(assert) { + pretender.put( + `/chat/api/chat_channels/${this.channel.id}/notifications_settings.json`, + () => { + return [ + 200, + { "Content-Type": "application/json" }, + membershipFixture(this.channel.id, { muted: true }), + ]; + } + ); + + const sk = selectKit(".channel-settings-view__muted-selector"); + await sk.expand(); + await sk.selectRowByName("Off"); + + assert.equal(sk.header().value(), "false"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-settings-view | Public channel - admin user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("admin actions", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + set(this.currentUser, "admin", true); + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.ok(exists(".close-btn")); + assert.ok(exists(".delete-btn")); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-settings-view | Archived Public channel - admin user", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("archive action", { + template: hbs`{{chat-channel-settings-view channel=channel}}`, + + beforeEach() { + set(this.currentUser, "admin", true); + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + this.siteSettings.chat_allow_archiving_channels = true; + this.set("channel", fabricators.chatChannel()); + }, + + async test(assert) { + assert.ok(exists(".archive-btn")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-title-test.js b/plugins/chat/test/javascripts/components/chat-channel-title-test.js new file mode 100644 index 00000000000..29f5526c769 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-title-test.js @@ -0,0 +1,173 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-channel-title", function (hooks) { + setupRenderingTest(hooks); + + componentTest("category channel", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-channel-title__category-badge").getAttribute("style"), + `color: #${this.channel.chatable.color}` + ); + assert.equal( + query(".chat-channel-title__name").innerText, + this.channel.title + ); + }, + }); + + componentTest("category channel - escapes title", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + title: "
evil
", + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".xss")); + }, + }); + + componentTest("category channel - read restricted", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + chatable: { read_restricted: true }, + }) + ); + }, + + async test(assert) { + assert.ok(exists(".d-icon-lock")); + }, + }); + + componentTest("category channel - not read restricted", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set( + "channel", + fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.categoryChannel, + chatable: { read_restricted: false }, + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".d-icon-lock")); + }, + }); + + componentTest("direct message channel - one user", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.directMessageChatChannel()); + }, + + async test(assert) { + const user = this.channel.chatable.users[0]; + + assert.ok( + exists(`.chat-user-avatar-container .avatar[title="${user.username}"]`) + ); + + assert.equal( + query(".chat-channel-title__name").innerText.trim(), + user.username + ); + }, + }); + + componentTest("direct message channel - multiple users", { + template: hbs`{{chat-channel-title channel=channel}}`, + + beforeEach() { + const channel = fabricators.directMessageChatChannel(); + + channel.chatable.users.push({ + id: 2, + username: "joffrey", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/31188e/{size}.png", + }); + + this.set("channel", channel); + }, + + async test(assert) { + const users = this.channel.chatable.users; + + assert.equal( + parseInt( + query(".chat-channel-title__users-count").innerText.trim(), + 10 + ), + users.length + ); + + assert.equal( + query(".chat-channel-title__name").innerText.trim(), + users.mapBy("username").join(", ") + ); + }, + }); + + componentTest("unreadIndicator", { + template: hbs`{{chat-channel-title channel=channel unreadIndicator=unreadIndicator}}`, + + beforeEach() { + const channel = fabricators.chatChannel({ + chatable_type: CHATABLE_TYPES.directMessageChannel, + }); + + const state = {}; + state[channel.id] = { + unread_count: 1, + }; + this.currentUser.set("chat_channel_tracking_state", state); + + this.set("channel", channel); + }, + + async test(assert) { + this.set("unreadIndicator", true); + + assert.ok(exists(".chat-channel-unread-indicator")); + + this.set("unreadIndicator", false); + + assert.notOk(exists(".chat-channel-unread-indicator")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js b/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js new file mode 100644 index 00000000000..426e0b3d180 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-toggle-view-test.js @@ -0,0 +1,104 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { click } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import I18n from "I18n"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-channel-toggle-view | closed channel", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("texts", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "closed" })); + }, + + async test(assert) { + assert.equal( + query("#chat-channel-toggle").innerText.trim(), + I18n.t("chat.channel_open.instructions") + ); + assert.equal( + query("#chat-channel-toggle-btn").innerText.trim(), + I18n.t("chat.channel_settings.open_channel") + ); + }, + }); + + componentTest("action", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "closed" })); + }, + + async test(assert) { + pretender.put( + `/chat/chat_channels/${this.channel.id}/change_status.json`, + () => { + return [200, { "Content-Type": "application/json" }, {}]; + } + ); + + await click("#chat-channel-toggle-btn"); + + assert.equal(this.channel.isClosed, false); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-channel-toggle-view | opened channel", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("texts", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "open" })); + }, + + async test(assert) { + assert.equal( + query("#chat-channel-toggle").innerText.trim(), + I18n.t("chat.channel_close.instructions") + ); + assert.equal( + query("#chat-channel-toggle-btn").innerText.trim(), + I18n.t("chat.channel_settings.close_channel") + ); + }, + }); + + componentTest("action", { + template: hbs`{{chat-channel-toggle-view channel=channel}}`, + + beforeEach() { + this.set("channel", fabricators.chatChannel({ status: "open" })); + }, + + async test(assert) { + pretender.put( + `/chat/chat_channels/${this.channel.id}/change_status.json`, + () => { + return [200, { "Content-Type": "application/json" }, {}]; + } + ); + + await click("#chat-channel-toggle-btn"); + + assert.equal(this.channel.isClosed, true); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js b/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js new file mode 100644 index 00000000000..718757683c4 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-channel-unread-indicator-test.js @@ -0,0 +1,91 @@ +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +const directMessageChannel = { + id: 1, + chatable_type: CHATABLE_TYPES.directMessageChannel, + chatable: { + users: [{ id: 1 }], + }, +}; + +const topicChannel = { + id: 2, + chatable_type: CHATABLE_TYPES.topicChannel, + chatable: { + users: [{ id: 1 }], + }, +}; + +module( + "Discourse Chat | Component | chat-channel-unread-indicator", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("has no unread", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + unread_count: 0, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.notOk(exists(".chat-channel-unread-indicator")); + }, + }); + + componentTest("has unread and no mentions", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [topicChannel.id]: { unread_count: 1 }, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator:not(.urgent)")); + }, + }); + + componentTest("has unread and mentions", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [topicChannel.id]: { unread_count: 1, unread_mentions: 1 }, + }); + this.set("channel", topicChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator.urgent")); + }, + }); + + componentTest("direct message channel | has unread", { + template: hbs`{{chat-channel-unread-indicator channel=channel}}`, + + beforeEach() { + set(this.currentUser, "chat_channel_tracking_state", { + [directMessageChannel.id]: { unread_count: 1 }, + }); + this.set("channel", directMessageChannel); + }, + + async test(assert) { + assert.ok(exists(".chat-channel-unread-indicator.urgent")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js b/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js new file mode 100644 index 00000000000..4a4ba9656df --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-dropdown-test.js @@ -0,0 +1,28 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { click } from "@ember/test-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-composer-dropdown", function (hooks) { + setupRenderingTest(hooks); + + componentTest("buttons", { + template: hbs`{{chat-composer-dropdown buttons=buttons}}`, + + async beforeEach() { + this.set("buttons", [{ id: "foo", icon: "times", action: () => {} }]); + }, + + async test(assert) { + await click(".chat-composer-dropdown__trigger-btn"); + + assert.ok(exists(".chat-composer-dropdown__item.foo")); + assert.ok( + exists(".chat-composer-dropdown__action-btn.foo .d-icon-times") + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js b/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js new file mode 100644 index 00000000000..732a0212ea5 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-inline-buttons-test.js @@ -0,0 +1,26 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-composer-inline-buttons", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("buttons", { + template: hbs`{{chat-composer-inline-buttons buttons=buttons}}`, + + async beforeEach() { + this.set("buttons", [{ id: "foo", icon: "times", action: () => {} }]); + }, + + async test(assert) { + assert.ok(exists(".chat-composer-inline-button.foo")); + assert.ok(exists(".chat-composer-inline-button.foo .d-icon-times")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js new file mode 100644 index 00000000000..c68d222c63a --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js @@ -0,0 +1,87 @@ +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-composer placeholder", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("direct message to self shows Jot something down", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + set(this.currentUser, "id", 1); + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "DirectMessageChannel", + chatable: { + users: [{ id: 1 }], + }, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Jot something down" + ); + }, + }); + + componentTest("direct message to multiple folks shows their names", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "DirectMessageChannel", + chatable: { + users: [ + { name: "Tomtom" }, + { name: "Steaky" }, + { username: "zorro" }, + ], + }, + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Chat with Tomtom, Steaky, @zorro" + ); + }, + }); + + componentTest("message to channel shows send message to channel name", { + template: hbs`{{chat-composer chatChannel=chatChannel}}`, + + beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ + chatable_type: "Category", + title: "just-cats", + }) + ); + }, + + async test(assert) { + assert.equal( + query(".chat-composer-input").placeholder, + "Chat with #just-cats" + ); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js new file mode 100644 index 00000000000..c2822af7daf --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js @@ -0,0 +1,158 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { click } from "@ember/test-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-composer-upload", function (hooks) { + setupRenderingTest(hooks); + + componentTest("file - uploading in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + progress: 50, + extension: ".pdf", + fileName: "test.pdf", + }); + }, + + async test(assert) { + assert.ok(exists(".upload-progress[value=50]")); + assert.strictEqual( + query(".uploading").innerText.trim(), + I18n.t("uploading") + ); + }, + }); + + componentTest("image - uploading in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + extension: ".png", + progress: 78, + fileName: "test.png", + }); + }, + + async test(assert) { + assert.ok(exists(".d-icon-far-image")); + assert.ok(exists(".upload-progress[value=78]")); + assert.strictEqual( + query(".uploading").innerText.trim(), + I18n.t("uploading") + ); + }, + }); + + componentTest("image - preprocessing upload in progress", { + template: hbs`{{chat-composer-upload upload=upload}}`, + + beforeEach() { + this.set("upload", { + extension: ".png", + progress: 78, + fileName: "test.png", + processing: true, + }); + }, + + async test(assert) { + assert.strictEqual( + query(".processing").innerText.trim(), + I18n.t("processing") + ); + }, + }); + + componentTest("file - upload complete", { + template: hbs`{{chat-composer-upload isDone=true upload=upload}}`, + + beforeEach() { + this.set("upload", { + type: ".pdf", + original_filename: "some file.pdf", + extension: "pdf", + }); + }, + + async test(assert) { + assert.ok(exists(".d-icon-file-alt")); + assert.strictEqual(query(".file-name").innerText.trim(), "some file.pdf"); + assert.strictEqual(query(".extension-pill").innerText.trim(), "pdf"); + }, + }); + + componentTest("image - upload complete", { + template: hbs`{{chat-composer-upload isDone=true upload=upload}}`, + + beforeEach() { + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + assert.ok(exists("img.preview-img[src='/images/avatar.png']")); + assert.strictEqual(query(".file-name").innerText.trim(), "bar_image.png"); + assert.strictEqual(query(".extension-pill").innerText.trim(), "png"); + }, + }); + + componentTest("removing completed upload", { + template: hbs`{{chat-composer-upload isDone=true upload=upload onCancel=(action "removeUpload" upload)}}`, + + beforeEach() { + this.set("uploadRemoved", false); + this.set("actions", { + removeUpload: () => { + this.set("uploadRemoved", true); + }, + }); + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + await click(".remove-upload"); + assert.strictEqual(this.uploadRemoved, true); + }, + }); + + componentTest("cancelling in progress upload", { + template: hbs`{{chat-composer-upload upload=upload onCancel=(action "removeUpload" upload)}}`, + + beforeEach() { + this.set("uploadRemoved", false); + this.set("actions", { + removeUpload: () => { + this.set("uploadRemoved", true); + }, + }); + this.set("upload", { + type: ".png", + original_filename: "bar_image.png", + extension: "png", + short_path: "/images/avatar.png", + }); + }, + + async test(assert) { + await click(".remove-upload"); + assert.strictEqual(this.uploadRemoved, true); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js new file mode 100644 index 00000000000..1cfceea4bc4 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js @@ -0,0 +1,163 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { + count, + createFile, + exists, +} from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { click, settled, waitFor } from "@ember/test-helpers"; +import { module } from "qunit"; +import { run } from "@ember/runloop"; + +const fakeUpload = { + type: ".png", + extension: "png", + name: "myfile.png", + short_path: "/images/avatar.png", +}; + +const mockUploadResponse = { + extension: "jpeg", + filesize: 126177, + height: 800, + human_filesize: "123 KB", + id: 202, + original_filename: "avatar.PNG.jpg", + retain_hours: null, + short_path: "/images/avatar.png", + short_url: "upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + thumbnail_height: 320, + thumbnail_width: 690, + url: "/images/avatar.png", + width: 1920, +}; + +function setupUploadPretender() { + pretender.post( + "/uploads.json", + () => { + return [200, { "Content-Type": "application/json" }, mockUploadResponse]; + }, + 500 // this delay is important to slow down the uploads a bit so we can click elements in the UI like the cancel button + ); +} + +module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { + setupRenderingTest(hooks); + + componentTest( + "loading uploads from an outside source (e.g. draft or editing message)", + { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader"}}`, + + async test(assert) { + this.appEvents = this.container.lookup("service:appEvents"); + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]); + await settled(); + + assert.strictEqual(count(".chat-composer-upload"), 1); + assert.strictEqual(exists(".chat-composer-upload"), true); + }, + } + ); + + componentTest("upload starts and completes", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + setupUploadPretender(); + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + const done = assert.async(); + this.appEvents = this.container.lookup("service:appEvents"); + this.appEvents.on( + "upload-mixin:chat-composer-uploader:upload-success", + (fileName, upload) => { + assert.strictEqual(fileName, "avatar.png"); + assert.deepEqual(upload, mockUploadResponse); + done(); + } + ); + + this.appEvents.trigger( + "upload-mixin:chat-composer-uploader:add-files", + createFile("avatar.png") + ); + + await waitFor(".chat-composer-upload"); + assert.strictEqual(count(".chat-composer-upload"), 1); + }, + }); + + componentTest("removing a completed upload", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + this.appEvents = this.container.lookup("service:appEvents"); + run(() => + this.appEvents.trigger("chat-composer:load-uploads", [fakeUpload]) + ); + assert.strictEqual(count(".chat-composer-upload"), 1); + + await click(".remove-upload"); + assert.strictEqual(count(".chat-composer-upload"), 0); + }, + }); + + componentTest("cancelling in progress upload", { + template: hbs`{{chat-composer-uploads fileUploadElementId="chat-widget-uploader" onUploadChanged=onUploadChanged}}`, + + beforeEach() { + setupUploadPretender(); + + this.set("changedUploads", null); + this.set("onUploadChanged", (uploads) => { + this.set("changedUploads", uploads); + }); + }, + + async test(assert) { + const image = createFile("avatar.png"); + const done = assert.async(); + this.appEvents = this.container.lookup("service:appEvents"); + + this.appEvents.on( + `upload-mixin:chat-composer-uploader:upload-cancelled`, + (fileId) => { + assert.strictEqual( + fileId.includes("uppy-avatar/"), + true, + "upload was cancelled" + ); + done(); + } + ); + + this.appEvents.trigger( + "upload-mixin:chat-composer-uploader:add-files", + image + ); + + await waitFor(".chat-composer-upload"); + assert.strictEqual(count(".chat-composer-upload"), 1); + + await click(".remove-upload"); + assert.strictEqual(count(".chat-composer-upload"), 0); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js b/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js new file mode 100644 index 00000000000..20b4e540168 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-emoji-avatar-test.js @@ -0,0 +1,26 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-emoji-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("uses an emoji as avatar", { + template: hbs`{{chat-emoji-avatar emoji=emoji}}`, + + async beforeEach() { + this.set("emoji", ":otter:"); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-emoji-avatar .chat-emoji-avatar-container .emoji[title=otter]` + ) + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js new file mode 100644 index 00000000000..ff22704010c --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js @@ -0,0 +1,243 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { click, fillIn, render } from "@ember/test-helpers"; + +function emojisResponse() { + return { + favorites: [ + { + name: "grinning", + tonable: false, + url: "/images/emoji/twitter/grinning.png?v=12", + group: "smileys_\u0026_emotion", + search_aliases: ["smiley_cat", "star_struck"], + }, + ], + "smileys_&_emotion": [ + { + name: "grinning", + tonable: false, + url: "/images/emoji/twitter/grinning.png?v=12", + group: "smileys_\u0026_emotion", + search_aliases: ["smiley_cat", "star_struck"], + }, + ], + "people_&_body": [ + { + name: "raised_hands", + tonable: true, + url: "/images/emoji/twitter/raised_hands.png?v=12", + group: "people_&_body", + search_aliases: [], + }, + { + name: "man_rowing_boat", + tonable: true, + url: "/images/emoji/twitter/man_rowing_boat.png?v=12", + group: "people_&_body", + search_aliases: [], + }, + ], + objects: [ + { + name: "womans_clothes", + tonable: false, + url: "/images/emoji/twitter/womans_clothes.png?v=12", + group: "objects", + search_aliases: [], + }, + ], + }; +} + +module("Discourse Chat | Component | chat-emoji-picker", function (hooks) { + setupRenderingTest(hooks); + + hooks.afterEach(function () { + this.emojiReactionStore.diversity = 1; + }); + + hooks.beforeEach(function () { + pretender.get("/chat/emojis.json", () => { + return [200, {}, emojisResponse()]; + }); + + this.chatEmojiPickerManager = this.container.lookup( + "service:chat-emoji-picker-manager" + ); + this.chatEmojiPickerManager.startFromComposer(() => {}); + this.chatEmojiPickerManager.addVisibleSections([ + "smileys_&_emotion", + "people_&_body", + "objects", + ]); + + this.emojiReactionStore = this.container.lookup( + "service:chat-emoji-reaction-store" + ); + }); + + test("When displaying navigation", async function (assert) { + await render(hbs``); + + assert.ok( + exists( + `.chat-emoji-picker__section-btn.active[data-section="favorites"]` + ), + "it renders first section as active" + ); + assert.ok( + exists( + `.chat-emoji-picker__section-btn[data-section="smileys_&_emotion"]` + ) + ); + assert.ok( + exists(`.chat-emoji-picker__section-btn[data-section="people_&_body"]`) + ); + assert.ok( + exists(`.chat-emoji-picker__section-btn[data-section="objects"]`) + ); + }); + + test("When changing tone scale", async function (assert) { + await render(hbs``); + await click(".chat-emoji-picker__fitzpatrick-modifier-btn.current.t1"); + await click(".chat-emoji-picker__fitzpatrick-modifier-btn.t6"); + + assert.ok( + exists(`img[src="/images/emoji/twitter/raised_hands/6.png"]`), + "it applies the tone to emojis" + ); + assert.ok( + exists(".chat-emoji-picker__fitzpatrick-modifier-btn.current.t6"), + "it changes the current scale to t6" + ); + }); + + test("When requesting section", async function (assert) { + await render(hbs``); + + assert.strictEqual( + document.querySelector("#ember-testing-container").scrollTop, + 0 + ); + + await click(`.chat-emoji-picker__section-btn[data-section="objects"]`); + + assert.ok( + document.querySelector("#ember-testing-container").scrollTop > 0, + "it scrolls to the section" + ); + }); + + test("When filtering emojis", async function (assert) { + await render(hbs``); + await fillIn(".dc-filter-input", "grinning"); + + assert.strictEqual( + queryAll(".chat-emoji-picker__sections > img").length, + 1, + "it filters the emojis list" + ); + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it filters the correct emoji" + ); + + await fillIn(".dc-filter-input", "Grinning"); + + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it is case insensitive" + ); + + await fillIn(".dc-filter-input", "smiley_cat"); + + assert.ok( + exists('.chat-emoji-picker__sections > img[alt="grinning"]'), + "it filters the correct emoji using search alias" + ); + }); + + test("When selecting an emoji", async function (assert) { + let selection; + this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + selection = emoji; + }; + await render(hbs``); + await click('img.emoji[data-emoji="grinning"]'); + + assert.strictEqual(selection, "grinning"); + }); + + test("When selecting a toned an emoji", async function (assert) { + let selection; + this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + selection = emoji; + }; + await render(hbs``); + this.emojiReactionStore.diversity = 1; + await click('img.emoji[data-emoji="man_rowing_boat"]'); + + assert.strictEqual(selection, "man_rowing_boat"); + + this.emojiReactionStore.diversity = 2; + await click('img.emoji[data-emoji="man_rowing_boat"]'); + + assert.strictEqual(selection, "man_rowing_boat:t2"); + }); + + test("When opening the picker", async function (assert) { + await render(hbs``); + + assert.ok(document.activeElement.classList.contains("dc-filter-input")); + }); + + test("When hovering an emoji", async function (assert) { + await render(hbs``); + + assert.strictEqual( + query( + '.chat-emoji-picker__section[data-section="people_&_body"] img.emoji:nth-child(1)' + ).title, + ":raised_hands:", + "first emoji has a title" + ); + + assert.strictEqual( + query( + '.chat-emoji-picker__section[data-section="people_&_body"] img.emoji:nth-child(2)' + ).title, + ":man_rowing_boat:", + "second emoji has a title" + ); + + await fillIn(".dc-filter-input", "grinning"); + assert.strictEqual( + query('img.emoji[data-emoji="grinning"]').title, + ":grinning:", + "filtered emoji have a title" + ); + + this.emojiReactionStore.diversity = 1; + await render(hbs``); + + assert.strictEqual( + query('img.emoji[data-emoji="man_rowing_boat"]').title, + ":man_rowing_boat:", + "it has a title without the scale as diversity value is 1" + ); + + this.emojiReactionStore.diversity = 2; + await render(hbs``); + + assert.strictEqual( + query('img.emoji[data-emoji="man_rowing_boat"]').title, + ":man_rowing_boat:t2:", + "it has a title with the scale" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-live-pane-test.js b/plugins/chat/test/javascripts/components/chat-live-pane-test.js new file mode 100644 index 00000000000..a02292651ae --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-live-pane-test.js @@ -0,0 +1,44 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import fabricators from "../helpers/fabricators"; +import { render } from "@ember/test-helpers"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import MockPresenceChannel from "../helpers/mock-presence-channel"; + +function mockChat(context) { + const mock = context.container.lookup("service:chat"); + mock.draftStore = {}; + mock.currentUser = context.currentUser; + mock.presenceChannel = MockPresenceChannel.create(); + return mock; +} + +module("Discourse Chat | Component | chat-live-pane", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("chat", mockChat(this)); + this.set("channel", fabricators.chatChannel()); + }); + + test("Shows skeleton when loading", async function (assert) { + pretender.get(`/chat/chat_channels.json`, () => response(this.channel)); + pretender.get(`/chat/:id/messages.json`, () => + response({ chat_messages: [], meta: { can_delete_self: true } }) + ); + + await render( + hbs`{{chat-live-pane loadingMorePast=true chat=chat chatChannel=channel}}` + ); + + assert.ok(exists(".chat-skeleton")); + + await render( + hbs`{{chat-live-pane loadingMoreFuture=true chat=chat chatChannel=channel}}` + ); + + assert.ok(exists(".chat-skeleton")); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-avatar-test.js b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js new file mode 100644 index 00000000000..04430313b2c --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-avatar-test.js @@ -0,0 +1,34 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("chat_webhook_event", { + template: hbs`{{chat-message-avatar message=message}}`, + + beforeEach() { + this.set("message", { chat_webhook_event: { emoji: ":heart:" } }); + }, + + async test(assert) { + assert.equal(query(".chat-emoji-avatar .emoji").title, "heart"); + }, + }); + + componentTest("user", { + template: hbs`{{chat-message-avatar message=message}}`, + + beforeEach() { + this.set("message", { user: { username: "discobot" } }); + }, + + async test(assert) { + assert.ok(exists('.chat-user-avatar [data-user-card="discobot"]')); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-collapser-test.js b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js new file mode 100644 index 00000000000..cb2be4c008b --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js @@ -0,0 +1,680 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click, render } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { + query, + queryAll, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { module, test } from "qunit"; + +const youtubeCooked = + "

written text

" + + '
Vid 1
' + + "

more written text

" + + '
Vid 2
' + + "

and even more

"; + +const animatedImageCooked = + "

written text

" + + '

' + + "

more written text

" + + '

' + + "

and even more

"; + +const externalImageCooked = + "

written text

" + + '

' + + "

more written text

" + + '

' + + "

and even more

"; + +const imageCooked = + "

written text

" + + '

shows alt

' + + "

more written text

" + + '

' + + "

and even more

" + + '

'; + +const galleryCooked = + "

written text

" + + '" + + "

more written text

"; + +const evilString = ""; +const evilStringEscaped = "<script>someeviltitle</script>"; + +module("Discourse Chat | Component | chat message collapser", function (hooks) { + setupRenderingTest(hooks); + + test("escapes uploads header", async function (assert) { + this.set("uploads", [{ original_filename: evilString }]); + await render(hbs`{{chat-message-collapser uploads=uploads}}`); + + assert.ok( + query(".chat-message-collapser-link-small").innerHTML.includes( + evilStringEscaped + ) + ); + }); +}); + +module( + "Discourse Chat | Component | chat message collapser youtube", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes youtube header", async function (assert) { + this.set("cooked", youtubeCooked.replace("ytId1", evilString)); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + query(".chat-message-collapser-link").href.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + }); + + componentTest("shows youtube link in header", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const link = document.querySelectorAll(".chat-message-collapser-link"); + + assert.equal(link.length, 2, "two youtube links rendered"); + assert.strictEqual( + link[0].href, + "https://www.youtube.com/watch?v=ytId1" + ); + assert.strictEqual( + link[1].href, + "https://www.youtube.com/watch?v=ytId2" + ); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + youtubeCooked.youtubeid; + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 3, "shows all written text"); + assert.strictEqual( + text[0].innerText, + "written text", + "first line of written text" + ); + assert.strictEqual( + text[1].innerText, + "more written text", + "third line of written text" + ); + assert.strictEqual( + text[2].innerText, + "and even more", + "fifth line of written text" + ); + }, + }); + + componentTest("collapses and expands cooked youtube", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", youtubeCooked); + }, + + async test(assert) { + const youtubeDivs = document.querySelectorAll(".onebox"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[data-youtube-id='ytId1']"), + "first youtube preview hidden" + ); + assert.ok( + visible(".onebox[data-youtube-id='ytId2']"), + "second youtube preview still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[data-youtube-id='ytId1']"), + "first youtube preview still visible" + ); + assert.notOk( + visible(".onebox[data-youtube-id='ytId2']"), + "second youtube preview hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(youtubeDivs.length, 2, "two youtube previews rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser images", + function (hooks) { + setupRenderingTest(hooks); + const imageTextCooked = "

A picture of Tomtom

"; + + componentTest("shows filename for one image", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{ original_filename: "tomtom.jpeg" }]); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "tomtom.jpeg" + ) + ); + }, + }); + + componentTest("shows number of files for multiple images", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{}, {}]); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "2 files" + ) + ); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set("cooked", imageTextCooked); + this.set("uploads", [{ original_filename: "tomtom.png" }]); + }, + + async test(assert) { + const uploads = ".chat-uploads"; + const chatImageUpload = ".chat-img-upload"; + + assert.ok(visible(uploads)); + assert.ok(visible(chatImageUpload)); + + await click(".chat-message-collapser-opened"); + + assert.notOk(visible(uploads)); + assert.notOk(visible(chatImageUpload)); + + await click(".chat-message-collapser-closed"); + + assert.ok(visible(uploads)); + assert.ok(visible(chatImageUpload)); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser animated image", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("shows links for animated image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("avatar.png")); + assert.ok(links[0].href.includes("avatar.png")); + + assert.ok( + links[1].innerText.trim().includes("d-logo-sketch-small.png") + ); + assert.ok(links[1].href.includes("d-logo-sketch-small.png")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 5, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands animated image onebox", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", animatedImageCooked); + }, + + async test(assert) { + const animatedOneboxes = document.querySelectorAll(".animated.onebox"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[src='/images/avatar.png']"), + "first onebox hidden" + ); + assert.ok( + visible(".onebox[src='/images/d-logo-sketch-small.png']"), + "second onebox still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[src='/images/avatar.png']"), + "first onebox still visible" + ); + assert.notOk( + visible(".onebox[src='/images/d-logo-sketch-small.png']"), + "second onebox hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(animatedOneboxes.length, 2, "two oneboxes rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser external image onebox", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("shows links for animated image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("http://cat1.com")); + assert.ok(links[0].href.includes("http://cat1.com")); + + assert.ok(links[1].innerText.trim().includes("http://cat2.com")); + assert.ok(links[1].href.includes("http://cat2.com")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 5, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands image oneboxes", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", externalImageCooked); + }, + + async test(assert) { + const imageOneboxes = document.querySelectorAll(".onebox"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible(".onebox[href='http://cat1.com']"), + "first onebox hidden" + ); + assert.ok( + visible(".onebox[href='http://cat2.com']"), + "second onebox still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible(".onebox[href='http://cat1.com']"), + "first onebox still visible" + ); + assert.notOk( + visible(".onebox[href='http://cat2.com']"), + "second onebox hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(imageOneboxes.length, 2, "two oneboxes rendered"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser images", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes link", async function (assert) { + this.set( + "cooked", + imageCooked + .replace("shows alt", evilString) + .replace("/images/d-logo-sketch-small.png", evilString) + ); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + queryAll(".chat-message-collapser-link-small")[0].innerHTML.includes( + evilStringEscaped + ) + ); + assert.ok( + queryAll(".chat-message-collapser-link-small")[1].innerHTML.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + }); + + componentTest("shows alt or links (if no alt) for linked image", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + + assert.ok(links[0].innerText.trim().includes("shows alt")); + assert.ok(links[0].href.includes("/images/avatar.png")); + + assert.ok( + links[1].innerText.trim().includes("/images/d-logo-sketch-small.png") + ); + assert.ok(links[1].href.includes("/images/d-logo-sketch-small.png")); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 6, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[2].innerText, "more written text"); + assert.strictEqual(text[4].innerText, "and even more"); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const images = document.querySelectorAll("img"); + + assert.equal(images.length, 3); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close first preview" + ); + + assert.notOk( + visible("img[src='/images/avatar.png']"), + "first image hidden" + ); + assert.ok( + visible("img[src='/images/d-logo-sketch-small.png']"), + "second image still visible" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(images.length, 3); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[1], + "close second preview" + ); + + assert.ok( + visible("img[src='/images/avatar.png']"), + "first image still visible" + ); + assert.notOk( + visible("img[src='/images/d-logo-sketch-small.png']"), + "second image hidden" + ); + + await click(".chat-message-collapser-closed"); + + assert.equal(images.length, 3); + }, + }); + + componentTest("does not show collapser for emoji images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", imageCooked); + }, + + async test(assert) { + const links = document.querySelectorAll( + "a.chat-message-collapser-link-small" + ); + const images = document.querySelectorAll("img"); + const collapser = document.querySelectorAll( + ".chat-message-collapser-opened" + ); + + assert.equal(links.length, 2); + assert.equal(images.length, 3, "shows images and emoji"); + assert.equal(collapser.length, 2); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat message collapser galleries", + function (hooks) { + setupRenderingTest(hooks); + + test("escapes title/link", async function (assert) { + this.set( + "cooked", + galleryCooked + .replace("https://imgur.com/gallery/yyVx5lJ", evilString) + .replace("Le tomtom album", evilString) + ); + await render(hbs`{{chat-message-collapser cooked=cooked}}`); + + assert.ok( + query(".chat-message-collapser-link-small").href.includes( + "%3Cscript%3Esomeeviltitle%3C/script%3E" + ) + ); + assert.strictEqual( + query(".chat-message-collapser-link-small").innerHTML.trim(), + "someeviltitle" + ); + }); + + componentTest("removes album title overlay", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.notOk(visible(".album-title"), "album title removed"); + }, + }); + + componentTest("shows gallery link", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.ok( + query(".chat-message-collapser-link-small").innerText.includes( + "Le tomtom album" + ) + ); + }, + }); + + componentTest("shows all user written text", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + const text = document.querySelectorAll(".chat-message-collapser p"); + + assert.equal(text.length, 2, "shows all written text"); + assert.strictEqual(text[0].innerText, "written text"); + assert.strictEqual(text[1].innerText, "more written text"); + }, + }); + + componentTest("collapses and expands images", { + template: hbs`{{chat-message-collapser cooked=cooked}}`, + + beforeEach() { + this.set("cooked", galleryCooked); + }, + + async test(assert) { + assert.ok(visible("img"), "image visible initially"); + + await click( + document.querySelectorAll(".chat-message-collapser-opened")[0], + "close preview" + ); + + assert.notOk(visible("img"), "image hidden"); + + await click(".chat-message-collapser-closed"); + + assert.ok(visible("img"), "image visible initially"); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-info-test.js b/plugins/chat/test/javascripts/components/chat-message-info-test.js new file mode 100644 index 00000000000..ffe946fda18 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-info-test.js @@ -0,0 +1,140 @@ +import Bookmark from "discourse/models/bookmark"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { module } from "qunit"; +import User from "discourse/models/user"; + +module("Discourse Chat | Component | chat-message-info", function (hooks) { + setupRenderingTest(hooks); + + componentTest("chat_webhook_event", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { chat_webhook_event: { username: "discobot" } }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__username").innerText.trim(), + this.message.chat_webhook_event.username + ); + assert.equal( + query(".chat-message-info__bot-indicator").textContent.trim(), + I18n.t("chat.bot") + ); + }, + }); + + componentTest("user", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { user: { username: "discobot" } }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__username").innerText.trim(), + this.message.user.username + ); + }, + }); + + componentTest("date", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + created_at: moment(), + }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__date")); + }, + }); + + componentTest("bookmark (with reminder)", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + reminder_at: moment(), + name: "some name", + }), + }); + }, + + async test(assert) { + assert.ok( + exists(".chat-message-info__bookmark .d-icon-discourse-bookmark-clock") + ); + }, + }); + + componentTest("bookmark (no reminder)", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + bookmark: Bookmark.create({ + name: "some name", + }), + }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__bookmark .d-icon-bookmark")); + }, + }); + + componentTest("user status", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + const status = { description: "off to dentist", emoji: "tooth" }; + this.set("message", { user: User.create({ status }) }); + }, + + async test(assert) { + assert.ok(exists(".chat-message-info__status .user-status-message")); + }, + }); + + componentTest("reviewable", { + template: hbs`{{chat-message-info message=message}}`, + + beforeEach() { + this.set("message", { + user: { username: "discobot" }, + user_flag_status: 0, + }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-info__flag > .svg-icon-title").title, + I18n.t("chat.you_flagged") + ); + + this.set("message", { + user: { username: "discobot" }, + reviewable_id: 1, + }); + + assert.equal( + query(".chat-message-info__flag a .svg-icon-title").title, + I18n.t("chat.flagged") + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js b/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js new file mode 100644 index 00000000000..5763df82a81 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-move-to-channel-modal-inner-test.js @@ -0,0 +1,32 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import fabricators from "../helpers/fabricators"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Component | chat-message-move-to-channel-modal-inner", + function (hooks) { + setupRenderingTest(hooks); + + test("channel title is escaped in instructions correctly", async function (assert) { + this.set( + "channel", + fabricators.chatChannel({ title: "" }) + ); + this.set("chat", { publicChannels: [this.channel] }); + this.set("selectedMessageIds", [1]); + + await render( + hbs`{{chat-message-move-to-channel-modal-inner selectedMessageIds=selectedMessageIds sourceChannel=channel chat=chat}}` + ); + + assert.ok( + query(".chat-message-move-to-channel-modal-inner").innerHTML.includes( + "<script>someeviltitle</script>" + ) + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js new file mode 100644 index 00000000000..9b1dd1af531 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js @@ -0,0 +1,104 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-reaction", function (hooks) { + setupRenderingTest(hooks); + + componentTest("accepts arbitrary class property", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart") class="foo"}}`, + + async test(assert) { + assert.ok(exists(".chat-message-reaction.foo")); + }, + }); + + componentTest("adds reacted class when user reacted", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" reacted=true)}}`, + + async test(assert) { + assert.ok(exists(".chat-message-reaction.reacted")); + }, + }); + + componentTest("adds reaction name as class", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + assert.ok(exists(`.chat-message-reaction[data-emoji-name="heart"]`)); + }, + }); + + componentTest("adds show class when count is positive", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" count=this.count)}}`, + + beforeEach() { + this.set("count", 0); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction.show")); + + this.set("count", 1); + + assert.ok(exists(".chat-message-reaction.show")); + }, + }); + + componentTest("title/alt attributes", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + assert.equal(query(".chat-message-reaction").title, ":heart:"); + assert.equal(query(".chat-message-reaction img").alt, ":heart:"); + }, + }); + + componentTest("count of reactions", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart" count=this.count)}}`, + + beforeEach() { + this.set("count", 0); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction .count")); + + this.set("count", 2); + + assert.equal(query(".chat-message-reaction .count").innerText, "2"); + }, + }); + + componentTest("reaction’s image", { + template: hbs`{{chat-message-reaction reaction=(hash emoji="heart")}}`, + + async test(assert) { + const src = query(".chat-message-reaction img").src; + assert.ok(/heart\.png/.test(src)); + }, + }); + + componentTest("click action", { + template: hbs`{{chat-message-reaction class="show" reaction=(hash emoji="heart" count=this.count) react=this.react}}`, + + beforeEach() { + this.set("count", 0); + this.set("react", () => { + this.set("count", 1); + }); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-reaction .count")); + + await click(".chat-message-reaction"); + + assert.equal(query(".chat-message-reaction .count").innerText, "1"); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-separator-test.js b/plugins/chat/test/javascripts/components/chat-message-separator-test.js new file mode 100644 index 00000000000..6282130718d --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-separator-test.js @@ -0,0 +1,44 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-separator", function (hooks) { + setupRenderingTest(hooks); + + componentTest("newest message", { + template: hbs`{{chat-message-separator message=message}}`, + + async beforeEach() { + this.set("message", { newestMessage: true }); + }, + + async test(assert) { + assert.equal( + query(".chat-message-separator.new-message .text").innerText.trim(), + I18n.t("chat.new_messages") + ); + }, + }); + + componentTest("first message of the day", { + template: hbs`{{chat-message-separator message=message}}`, + + async beforeEach() { + this.set("date", moment().format("LLL")); + this.set("message", { firstMessageOfTheDayAt: this.date }); + }, + + async test(assert) { + assert.equal( + query( + ".chat-message-separator.first-daily-message .text" + ).innerText.trim(), + this.date + ); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-test.js b/plugins/chat/test/javascripts/components/chat-message-test.js new file mode 100644 index 00000000000..ec688ab127c --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-test.js @@ -0,0 +1,119 @@ +import User from "discourse/models/user"; +import { render, waitFor } from "@ember/test-helpers"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | chat-message", function (hooks) { + setupRenderingTest(hooks); + + function generateMessageProps(messageData = {}) { + const chatChannel = ChatChannel.create({ + chatable: { id: 1 }, + chatable_type: "Category", + id: 9, + title: "Site", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 0, + muted: false, + }, + }); + return { + message: ChatMessage.create( + Object.assign( + { + id: 178, + message: "from deleted user", + cooked: "

from deleted user

", + excerpt: "

from deleted user

", + created_at: "2021-07-22T08:14:16.950Z", + flag_count: 0, + user: User.create({ username: "someguy", id: 1424 }), + edited: false, + }, + messageData + ) + ), + canInteractWithChat: true, + details: { + can_delete_self: true, + can_delete_others: true, + can_flag: true, + user_silenced: false, + can_moderate: true, + }, + chatChannel, + setReplyTo: () => {}, + replyMessageClicked: () => {}, + editButtonClicked: () => {}, + afterExpand: () => {}, + selectingMessages: false, + onStartSelectingMessages: () => {}, + onSelectMessage: () => {}, + bulkSelectMessages: () => {}, + fullPage: false, + afterReactionAdded: () => {}, + onHoverMessage: () => {}, + }; + } + + const template = hbs`{{chat-message + message=message + canInteractWithChat=canInteractWithChat + details=this.details + chatChannel=chatChannel + setReplyTo=setReplyTo + replyMessageClicked=replyMessageClicked + editButtonClicked=editButtonClicked + selectingMessages=selectingMessages + onStartSelectingMessages=onStartSelectingMessages + onSelectMessage=onSelectMessage + bulkSelectMessages=bulkSelectMessages + fullPage=fullPage + onHoverMessage=onHoverMessage + afterReactionAdded=reStickScrollIfNeeded + }}`; + + test("Message with edits", async function (assert) { + this.setProperties(generateMessageProps({ edited: true })); + await render(template); + assert.ok( + exists(".chat-message-edited"), + "has the correct edited css class" + ); + }); + + test("Deleted message", async function (assert) { + this.setProperties(generateMessageProps({ deleted_at: moment() })); + await render(template); + assert.ok( + exists(".chat-message-deleted .chat-message-expand"), + "has the correct deleted css class and expand button within" + ); + }); + + test("Hidden message", async function (assert) { + this.setProperties(generateMessageProps({ hidden: true })); + await render(template); + assert.ok( + exists(".chat-message-hidden .chat-message-expand"), + "has the correct hidden css class and expand button within" + ); + }); + + test("Message marked as visible", async function (assert) { + this.setProperties(generateMessageProps()); + + await render(template); + await waitFor("div[data-visible=true]"); + + assert.ok( + exists(".chat-message-container[data-visible=true]"), + "message is marked as visible" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-message-text-test.js b/plugins/chat/test/javascripts/components/chat-message-text-test.js new file mode 100644 index 00000000000..938cb5bc761 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-message-text-test.js @@ -0,0 +1,76 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; + +module("Discourse Chat | Component | chat-message-text", function (hooks) { + setupRenderingTest(hooks); + + componentTest("yields", { + template: hbs`{{#chat-message-text cooked=cooked uploads=uploads}}
{{/chat-message-text}}`, + + beforeEach() { + this.set("cooked", "

"); + }, + + async test(assert) { + assert.ok(exists(".yield-me")); + }, + }); + + componentTest("shows collapsed", { + template: hbs`{{chat-message-text cooked=cooked uploads=uploads}}`, + + beforeEach() { + this.set( + "cooked", + '
' + ); + }, + + async test(assert) { + assert.ok(exists(".chat-message-collapser")); + }, + }); + + componentTest("does not collapse a non-image onebox", { + template: hbs`{{chat-message-text cooked=cooked}}`, + + beforeEach() { + this.set( + "cooked", + '

' + ); + }, + + async test(assert) { + assert.notOk(exists(".chat-message-collapser")); + }, + }); + + componentTest("shows edits - regular message", { + template: hbs`{{chat-message-text cooked=cooked edited=true}}`, + + beforeEach() { + this.set("cooked", "

"); + }, + + async test(assert) { + assert.ok(exists(".chat-message-edited")); + }, + }); + + componentTest("shows edits - collapsible message", { + template: hbs`{{chat-message-text cooked=cooked edited=true}}`, + + beforeEach() { + this.set("cooked", '
'); + }, + + async test(assert) { + assert.ok(exists(".chat-message-edited")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js b/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js new file mode 100644 index 00000000000..84468a68b1a --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-replying-indicator-test.js @@ -0,0 +1,182 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import fabricators from "../helpers/fabricators"; +import MockPresenceChannel from "../helpers/mock-presence-channel"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-replying-indicator", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("not displayed when no one is replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + assert.notOk(exists(".chat-replying-indicator__text")); + }, + }); + + componentTest("displays indicator when user is replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + this.set("presenceChannel.users", [sam]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} is typing` + ); + }, + }); + + componentTest("displays indicator when 2 or 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + this.set("presenceChannel.users", [sam, mark]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} and ${mark.username} are typing` + ); + }, + }); + + componentTest("displays indicator when 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + const joffrey = { id: 3, username: "joffrey" }; + this.set("presenceChannel.users", [sam, mark, joffrey]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username}, ${mark.username} and ${joffrey.username} are typing` + ); + }, + }); + + componentTest("displays indicator when more than 3 users are replying", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + const mark = { id: 2, username: "mark" }; + const joffrey = { id: 3, username: "joffrey" }; + const taylor = { id: 4, username: "taylor" }; + this.set("presenceChannel.users", [sam, mark, joffrey, taylor]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username}, ${mark.username} and 2 others are typing` + ); + }, + }); + + componentTest("filters current user from list of repliers", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + }) + ); + }, + + async test(assert) { + const sam = { id: 1, username: "sam" }; + this.set("presenceChannel.users", [sam, this.currentUser]); + + assert.equal( + query(".chat-replying-indicator__text").innerText, + `${sam.username} is typing` + ); + }, + }); + + componentTest("resets presence when channel is draft", { + template: hbs`{{chat-replying-indicator presenceChannel=presenceChannel chatChannel=chatChannel}}`, + + async beforeEach() { + this.set("chatChannel", fabricators.chatChannel()); + this.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/${this.chatChannel.id}`, + subscribed: true, + }) + ); + }, + + async test(assert) { + assert.ok(this.presenceChannel.subscribed); + + this.set("chatChannel", fabricators.chatChannel({ isDraft: true })); + + assert.notOk(this.presenceChannel.subscribed); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js new file mode 100644 index 00000000000..3d4145ba53a --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-retention-reminder-test.js @@ -0,0 +1,93 @@ +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import { set } from "@ember/object"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; +import { module } from "qunit"; + +module( + "Discourse Chat | Component | chat-retention-reminder", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("Shows for public channels when user needs it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "Category" }) + ); + set(this.currentUser, "needs_channel_retention_reminder", true); + this.siteSettings.chat_channel_retention_days = 100; + }, + + async test(assert) { + assert.equal( + query(".chat-retention-reminder-text").innerText.trim(), + I18n.t("chat.retention_reminders.public", { days: 100 }) + ); + }, + }); + + componentTest( + "Doesn't show for public channels when user has dismissed it", + { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "Category" }) + ); + set(this.currentUser, "needs_channel_retention_reminder", false); + this.siteSettings.chat_channel_retention_days = 100; + }, + + async test(assert) { + assert.notOk(exists(".chat-retention-reminder")); + }, + } + ); + + componentTest("Shows for direct message channels when user needs it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "DirectMessageChannel" }) + ); + set(this.currentUser, "needs_dm_retention_reminder", true); + this.siteSettings.chat_dm_retention_days = 100; + }, + + async test(assert) { + assert.equal( + query(".chat-retention-reminder-text").innerText.trim(), + I18n.t("chat.retention_reminders.dm", { days: 100 }) + ); + }, + }); + + componentTest("Doesn't show for dm channels when user has dismissed it", { + template: hbs`{{chat-retention-reminder chatChannel=chatChannel}}`, + + async beforeEach() { + this.set( + "chatChannel", + ChatChannel.create({ chatable_type: "DirectMessageChannel" }) + ); + set(this.currentUser, "needs_dm_retention_reminder", false); + this.siteSettings.chat_dm_retention_days = 100; + }, + + async test(assert) { + assert.notOk(exists(".chat-retention-reminder")); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/chat-upload-test.js b/plugins/chat/test/javascripts/components/chat-upload-test.js new file mode 100644 index 00000000000..989da1f8be4 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-upload-test.js @@ -0,0 +1,118 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; +import { settled } from "@ember/test-helpers"; + +const IMAGE_FIXTURE = { + id: 290, + url: null, // Nulled out to avoid actually setting the img src - avoids an HTTP request + original_filename: "image.jpg", + filesize: 172214, + width: 1024, + height: 768, + thumbnail_width: 666, + thumbnail_height: 500, + extension: "jpeg", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + retain_hours: null, + human_filesize: "168 KB", + dominant_color: "788370", // rgb(120, 131, 112) +}; + +const VIDEO_FIXTURE = { + id: 290, + url: null, // Nulled out to avoid actually setting the img src - avoids an HTTP request + original_filename: "video.mp4", + filesize: 172214, + width: 1024, + height: 768, + thumbnail_width: 666, + thumbnail_height: 500, + extension: "mp4", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.mp4", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.mp4", + retain_hours: null, + human_filesize: "168 KB", +}; + +const TXT_FIXTURE = { + id: 290, + url: "https://example.com/file.txt", + original_filename: "file.txt", + filesize: 172214, + extension: "txt", + short_url: "upload://mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + short_path: "/uploads/short-url/mnCnqY5tunCFw2qMgtPnu1mu1C9.jpeg", + retain_hours: null, + human_filesize: "168 KB", +}; + +module("Discourse Chat | Component | chat-upload", function (hooks) { + setupRenderingTest(hooks); + + componentTest("with an image", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", IMAGE_FIXTURE); + }, + + async test(assert) { + assert.true(exists("img.chat-img-upload"), "displays as an image"); + const image = query("img.chat-img-upload"); + assert.strictEqual(image.loading, "lazy", "is lazy loading"); + + assert.strictEqual( + image.style.backgroundColor, + "rgb(120, 131, 112)", + "sets background to dominant color" + ); + + image.dispatchEvent(new Event("load")); // Fake that the image has loaded + await settled(); + + assert.strictEqual( + image.style.backgroundColor, + "", + "removes the background color once the image has loaded" + ); + }, + }); + + componentTest("with a video", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", VIDEO_FIXTURE); + }, + + async test(assert) { + assert.true(exists("video.chat-video-upload"), "displays as an video"); + const video = query("video.chat-video-upload"); + assert.ok(video.hasAttribute("controls"), "has video controls"); + assert.strictEqual( + video.getAttribute("preload"), + "metadata", + "video has correct preload settings" + ); + }, + }); + + componentTest("non image upload", { + template: hbs`{{chat-upload upload=upload}}`, + + beforeEach() { + this.set("upload", TXT_FIXTURE); + }, + + async test(assert) { + assert.true(exists("a.chat-other-upload"), "displays as a link"); + const link = query("a.chat-other-upload"); + assert.strictEqual(link.href, TXT_FIXTURE.url, "has the correct URL"); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-user-avatar-test.js b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js new file mode 100644 index 00000000000..3bed00c31ed --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-user-avatar-test.js @@ -0,0 +1,55 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +const user = { + id: 1, + username: "markvanlan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/m/48db29/{size}.png", +}; + +module("Discourse Chat | Component | chat-user-avatar", function (hooks) { + setupRenderingTest(hooks); + + componentTest("user is not online", { + template: hbs`{{chat-user-avatar chat=chat user=user}}`, + + async beforeEach() { + this.set("user", user); + this.set("chat", { presenceChannel: { users: [] } }); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-user-avatar .chat-user-avatar-container[data-user-card=${user.username}] .avatar[title=${user.username}]` + ) + ); + assert.notOk(exists(".chat-user-avatar.is-online")); + }, + }); + + componentTest("user is online", { + template: hbs`{{chat-user-avatar chat=chat user=user}}`, + + async beforeEach() { + this.set("user", user); + this.set("chat", { + presenceChannel: { users: [{ id: user.id }] }, + }); + }, + + async test(assert) { + assert.ok( + exists( + `.chat-user-avatar .chat-user-avatar-container[data-user-card=${user.username}] .avatar[title=${user.username}]` + ) + ); + assert.ok(exists(".chat-user-avatar.is-online")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/chat-user-display-name-test.js b/plugins/chat/test/javascripts/components/chat-user-display-name-test.js new file mode 100644 index 00000000000..26e56d20536 --- /dev/null +++ b/plugins/chat/test/javascripts/components/chat-user-display-name-test.js @@ -0,0 +1,76 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { module } from "qunit"; + +function displayName() { + return query(".chat-user-display-name").innerText.trim(); +} + +module( + "Discourse Chat | Component | chat-user-display-name | prioritize username in UX", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("username and no name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = true; + this.set("user", { username: "bob", name: null }); + }, + + async test(assert) { + assert.equal(displayName(), "bob"); + }, + }); + + componentTest("username and name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = true; + this.set("user", { username: "bob", name: "Bobcat" }); + }, + + async test(assert) { + assert.equal(displayName(), "bob — Bobcat"); + }, + }); + } +); + +module( + "Discourse Chat | Component | chat-user-display-name | prioritize name in UX", + function (hooks) { + setupRenderingTest(hooks); + + componentTest("no name", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = false; + this.set("user", { username: "bob", name: null }); + }, + + async test(assert) { + assert.equal(displayName(), "bob"); + }, + }); + + componentTest("name and username", { + template: hbs`{{chat-user-display-name user=user}}`, + + async beforeEach() { + this.siteSettings.prioritize_username_in_ux = false; + this.set("user", { username: "bob", name: "Bobcat" }); + }, + + async test(assert) { + assert.equal(displayName(), "Bobcat — bob"); + }, + }); + } +); diff --git a/plugins/chat/test/javascripts/components/collapser-test.js b/plugins/chat/test/javascripts/components/collapser-test.js new file mode 100644 index 00000000000..8409c97a991 --- /dev/null +++ b/plugins/chat/test/javascripts/components/collapser-test.js @@ -0,0 +1,45 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query, visible } from "discourse/tests/helpers/qunit-helpers"; +import { module } from "qunit"; +import { htmlSafe } from "@ember/template"; + +module("Discourse Chat | Component | collapser", function (hooks) { + setupRenderingTest(hooks); + + componentTest("renders header", { + template: hbs`{{collapser header=header}}`, + + beforeEach() { + this.set("header", htmlSafe("
tomtom
")); + }, + + async test(assert) { + const element = query(".cat"); + + assert.ok(exists(element)); + }, + }); + + componentTest("collapses and expands yielded body", { + template: hbs`{{#collapser}}
body text
{{/collapser}}`, + + test: async function (assert) { + const openButton = ".chat-message-collapser-closed"; + const closeButton = ".chat-message-collapser-opened"; + const body = ".cat"; + + assert.ok(visible(body)); + await click(closeButton); + + assert.notOk(visible(body)); + + await click(openButton); + + assert.ok(visible(body)); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/dc-filter-input-test.js b/plugins/chat/test/javascripts/components/dc-filter-input-test.js new file mode 100644 index 00000000000..10e5e75f855 --- /dev/null +++ b/plugins/chat/test/javascripts/components/dc-filter-input-test.js @@ -0,0 +1,56 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { fillIn, render, triggerEvent } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | dc-filter-input", function (hooks) { + setupRenderingTest(hooks); + + test("Left icon", async function (assert) { + await render(hbs``); + + assert.ok(exists(".d-icon-bell.-left")); + }); + + test("Right icon", async function (assert) { + await render(hbs``); + + assert.ok(exists(".d-icon-bell.-right")); + }); + + test("Class attribute", async function (assert) { + await render(hbs``); + + assert.ok(exists(".dc-filter-input-container.foo")); + }); + + test("Html attributes", async function (assert) { + await render(hbs``); + + assert.ok(exists('.dc-filter-input[data-foo="1"]')); + assert.ok(exists('.dc-filter-input[placeholder="bar"]')); + }); + + test("Filter action", async function (assert) { + this.set("value", null); + this.set("action", (event) => { + this.set("value", event.target.value); + }); + await render(hbs``); + await fillIn(".dc-filter-input", "foo"); + + assert.equal(this.value, "foo"); + }); + + test("Focused state", async function (assert) { + await render(hbs``); + await triggerEvent(".dc-filter-input", "focusin"); + + assert.ok(exists(".dc-filter-input-container.is-focused")); + + await triggerEvent(".dc-filter-input", "focusout"); + + assert.notOk(exists(".dc-filter-input-container.is-focused")); + }); +}); diff --git a/plugins/chat/test/javascripts/components/direct-message-creator-test.js b/plugins/chat/test/javascripts/components/direct-message-creator-test.js new file mode 100644 index 00000000000..8bcbd42ec05 --- /dev/null +++ b/plugins/chat/test/javascripts/components/direct-message-creator-test.js @@ -0,0 +1,167 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import { click, fillIn } from "@ember/test-helpers"; +import hbs from "htmlbars-inline-precompile"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { createDirectMessageChannelDraft } from "discourse/plugins/chat/discourse/models/chat-channel"; +import { Promise } from "rsvp"; +import fabricators from "../helpers/fabricators"; +import { module } from "qunit"; + +function mockChat(context, options = {}) { + const mock = context.container.lookup("service:chat"); + mock.searchPossibleDirectMessageUsers = () => { + return Promise.resolve({ + users: options.users || [{ username: "hawk" }, { username: "mark" }], + }); + }; + mock.getDmChannelForUsernames = () => { + return Promise.resolve({ chat_channel: fabricators.chatChannel() }); + }; + return mock; +} + +module("Discourse Chat | Component | direct-message-creator", function (hooks) { + setupRenderingTest(hooks); + + componentTest("search", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + assert.ok(exists("li.user[data-username='hawk']")); + }, + }); + + componentTest("select/deselect", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + assert.notOk(exists(".selected-user")); + + await fillIn(".filter-usernames", "hawk"); + await click("li.user[data-username='hawk']"); + + assert.ok(exists(".selected-user")); + + await click(".selected-user"); + + assert.notOk(exists(".selected-user")); + }, + }); + + componentTest("no search results", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this, { users: [] })); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "bad cat"); + + assert.ok(exists(".no-results")); + }, + }); + + componentTest("loads user on first load", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + assert.ok(exists("li.user[data-username='hawk']")); + assert.ok(exists("li.user[data-username='mark']")); + }, + }); + + componentTest("do not load more users after selection", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await click("li.user[data-username='hawk']"); + + assert.notOk(exists("li.user[data-username='mark']")); + }, + }); + + componentTest("apply is-focused to filter-area on focus input", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await click(".filter-usernames"); + + assert.ok(exists(".filter-area.is-focused")); + + await click(".test-blur"); + + assert.notOk(exists(".filter-area.is-focused")); + }, + }); + + componentTest("state is reset on channel change", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + this.set("chat", mockChat(this)); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + + assert.equal(query(".filter-usernames").value, "hawk"); + + this.set("channel", fabricators.chatChannel()); + this.set("channel", createDirectMessageChannelDraft()); + + assert.equal(query(".filter-usernames").value, ""); + assert.ok(exists(".filter-area.is-focused")); + assert.ok(exists("li.user[data-username='hawk']")); + }, + }); + + componentTest("shows user status", { + template: hbs`{{direct-message-creator channel=channel chat=chat}}`, + + beforeEach() { + const userWithStatus = { + username: "hawk", + status: { emoji: "tooth", description: "off to dentist" }, + }; + const chat = mockChat(this, { users: [userWithStatus] }); + this.set("chat", chat); + this.set("channel", createDirectMessageChannelDraft()); + }, + + async test(assert) { + await fillIn(".filter-usernames", "hawk"); + assert.ok(exists(".user-status-message")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/components/on-visibility-action-test.js b/plugins/chat/test/javascripts/components/on-visibility-action-test.js new file mode 100644 index 00000000000..8eea7855bfe --- /dev/null +++ b/plugins/chat/test/javascripts/components/on-visibility-action-test.js @@ -0,0 +1,29 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { render, waitUntil } from "@ember/test-helpers"; +import { module, test } from "qunit"; + +module("Discourse Chat | Component | on-visibility-action", function (hooks) { + setupRenderingTest(hooks); + + test("Calling an action on visibility gained", async function (assert) { + this.set("value", null); + this.set("display", false); + this.set("action", () => { + this.set("value", "foo"); + }); + + this.set("root", document.querySelector("#ember-testing")); + + await render( + hbs`{{#if display}}{{on-visibility-action action=action root=root}}{{/if}}` + ); + + assert.equal(this.value, null); + + this.set("display", true); + await waitUntil(() => this.value !== null); + + assert.equal(this.value, "foo"); + }); +}); diff --git a/plugins/chat/test/javascripts/components/sidebar-channels-test.js b/plugins/chat/test/javascripts/components/sidebar-channels-test.js new file mode 100644 index 00000000000..053c662cfd6 --- /dev/null +++ b/plugins/chat/test/javascripts/components/sidebar-channels-test.js @@ -0,0 +1,78 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { + setup as setupChatStub, + teardown as teardownChatStub, +} from "../helpers/chat-stub"; +import { module } from "qunit"; + +module("Discourse Chat | Component | sidebar-channels", function (hooks) { + setupRenderingTest(hooks); + + componentTest("default", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.ok(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("chat is on chat page", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { fullScreenChatOpen: true }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.ok(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("none of the conditions are fulfilled", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { userCanChat: false, fullScreenChatOpen: false }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.notOk(exists("[data-chat-channel-id]")); + }, + }); + + componentTest("user cant chat", { + template: hbs`{{sidebar-channels}}`, + + beforeEach() { + setupChatStub(this, { userCanChat: false }); + }, + + afterEach() { + teardownChatStub(); + }, + + async test(assert) { + assert.notOk(exists("[data-chat-channel-id]")); + }, + }); +}); diff --git a/plugins/chat/test/javascripts/helpers/chat-pretenders.js b/plugins/chat/test/javascripts/helpers/chat-pretenders.js new file mode 100644 index 00000000000..98a71f824f8 --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/chat-pretenders.js @@ -0,0 +1,165 @@ +import { + chatChannels, + directMessageChannels, + generateChatView, +} from "discourse/plugins/chat/chat-fixtures"; + +import { cloneJSON } from "discourse-common/lib/object"; +import User from "discourse/models/user"; + +export function baseChatPretenders(server, helper) { + server.get("/chat/:chatChannelId/messages.json", () => + helper.response(generateChatView(User.current())) + ); + + server.post("/chat/:chatChannelId.json", () => { + return helper.response({ success: "OK" }); + }); + + server.get("/notifications", () => { + return helper.response({ + notifications: [ + { + id: 42, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "First notification", + post_number: null, + topic_id: null, + slug: null, + data: { + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 43, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Second notification", + post_number: null, + topic_id: null, + slug: null, + data: { + identifier: "engineers", + is_group: true, + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 44, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Third notification", + post_number: null, + topic_id: null, + slug: null, + data: { + identifier: "all", + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + mentioned_by_username: "hawk", + }, + }, + { + id: 45, + user_id: 1, + notification_type: 31, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Fourth notification", + post_number: null, + topic_id: null, + slug: null, + data: { + message: "chat.invitation_notification", + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + invited_by_username: "hawk", + }, + }, + { + id: 46, + user_id: 1, + notification_type: 29, + read: false, + high_priority: true, + created_at: "2021-01-01 12:00:00 UTC", + fancy_title: "Fifth notification", + post_number: null, + topic_id: null, + slug: null, + data: { + chat_message_id: 174, + chat_channel_id: 9, + chat_channel_title: "Site", + is_direct_message_channel: true, + mentioned_by_username: "hawk", + }, + }, + ], + seen_notification_id: null, + }); + }); + + server.get("/chat/lookup/:messageId.json", () => + helper.response(generateChatView(User.current())) + ); + + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + + server.get("/chat/api/category-chatables/:categoryId/permissions.json", () => + helper.response({ allowed_groups: ["@everyone"], private: false }) + ); +} + +export function directMessageChannelPretender( + server, + helper, + opts = { unread_count: 0, muted: false } +) { + let copy = cloneJSON(directMessageChannels[0]); + copy.chat_channel.current_user_membership.unread_count = opts.unread_count; + copy.chat_channel.current_user_membership.muted = opts.muted; + server.get("/chat/chat_channels/75.json", () => helper.response(copy)); +} + +export function chatChannelPretender(server, helper, changes = []) { + // changes is [{ id: X, unread_count: Y, muted: true}] + let copy = cloneJSON(chatChannels); + changes.forEach((change) => { + let found; + found = copy.public_channels.find((c) => c.id === change.id); + if (found) { + found.current_user_membership.unread_count = change.unread_count; + found.current_user_membership.muted = change.muted; + } + if (!found) { + found = copy.direct_message_channels.find((c) => c.id === change.id); + if (found) { + found.current_user_membership.unread_count = change.unread_count; + found.current_user_membership.muted = change.muted; + } + } + }); + server.get("/chat/chat_channels.json", () => helper.response(copy)); +} diff --git a/plugins/chat/test/javascripts/helpers/chat-stub.js b/plugins/chat/test/javascripts/helpers/chat-stub.js new file mode 100644 index 00000000000..7d9420b3c0b --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/chat-stub.js @@ -0,0 +1,32 @@ +import fabricators from "../helpers/fabricators"; +import { isPresent } from "@ember/utils"; +import Service from "@ember/service"; + +let publicChannels; +let userCanChat; +let fullScreenChatOpen; + +class ChatStub extends Service { + userCanChat = userCanChat; + publicChannels = publicChannels; + fullScreenChatOpen = fullScreenChatOpen; +} + +export function setup(context, options = {}) { + context.registry.register("service:chat-stub", ChatStub); + context.registry.injection("component", "chat", "service:chat-stub"); + + publicChannels = isPresent(options.publicChannels) + ? options.publicChannels + : [fabricators.chatChannel()]; + userCanChat = isPresent(options.userCanChat) ? options.userCanChat : true; + fullScreenChatOpen = isPresent(options.fullScreenChatOpen) + ? options.fullScreenChatOpen + : false; +} + +export function teardown() { + publicChannels = []; + userCanChat = true; + fullScreenChatOpen = false; +} diff --git a/plugins/chat/test/javascripts/helpers/fabricator.js b/plugins/chat/test/javascripts/helpers/fabricator.js new file mode 100644 index 00000000000..8a513c48128 --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/fabricator.js @@ -0,0 +1,21 @@ +import { cloneJSON } from "discourse-common/lib/object"; + +// heavily inspired by https://github.com/travelperk/fabricator +export function Fabricator(Model, attributes = {}) { + return (opts) => fabricate(Model, attributes, opts); +} + +function fabricate(Model, attributes, opts = {}) { + if (typeof attributes === "function") { + return attributes(); + } + + const extendedModel = cloneJSON({ ...attributes, ...opts }); + const props = {}; + + for (const [key, value] of Object.entries(extendedModel)) { + props[key] = typeof value === "function" ? value() : value; + } + + return Model.create(props); +} diff --git a/plugins/chat/test/javascripts/helpers/fabricators.js b/plugins/chat/test/javascripts/helpers/fabricators.js new file mode 100644 index 00000000000..a316792cba9 --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/fabricators.js @@ -0,0 +1,50 @@ +import ChatChannel, { + CHATABLE_TYPES, +} from "discourse/plugins/chat/discourse/models/chat-channel"; +import EmberObject from "@ember/object"; +import { Fabricator } from "./fabricator"; + +const userFabricator = Fabricator(EmberObject, { + id: 1, + username: "hawk", + name: null, + avatar_template: "/letter_avatar_proxy/v3/letter/t/41988e/{size}.png", +}); + +const categoryChatableFabricator = Fabricator(EmberObject, { + id: 1, + color: "D56353", + read_restricted: false, + name: "My category", +}); + +const directChannelChatableFabricator = Fabricator(EmberObject, { + users: [userFabricator({ id: 1, username: "bob" })], +}); + +export default { + chatChannel: Fabricator(ChatChannel, { + id: 1, + chatable_type: CHATABLE_TYPES.categoryChannel, + status: "open", + title: "My category title", + name: "My category name", + chatable: categoryChatableFabricator(), + last_message_sent_at: "2021-11-08T21:26:05.710Z", + }), + + chatChannelMessage: Fabricator(EmberObject, { + id: 1, + chat_channel_id: 1, + user_id: 1, + cooked: "This is a test message", + }), + + directMessageChatChannel: Fabricator(ChatChannel, { + id: 1, + chatable_type: CHATABLE_TYPES.directMessageChannel, + status: "open", + chatable: directChannelChatableFabricator(), + last_message_sent_at: "2021-11-08T21:26:05.710Z", + }), +}; diff --git a/plugins/chat/test/javascripts/helpers/mock-presence-channel.js b/plugins/chat/test/javascripts/helpers/mock-presence-channel.js new file mode 100644 index 00000000000..320529dfbde --- /dev/null +++ b/plugins/chat/test/javascripts/helpers/mock-presence-channel.js @@ -0,0 +1,15 @@ +import EmberObject from "@ember/object"; + +export default class MockPresenceChannel extends EmberObject { + users = []; + name = null; + subscribed = false; + + async unsubscribe() { + this.set("subscribed", false); + } + + async subscribe() { + this.set("subscribed", true); + } +} diff --git a/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js b/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js new file mode 100644 index 00000000000..9d352560bce --- /dev/null +++ b/plugins/chat/test/javascripts/integration/components/user-menu/chat-notifications-list-test.js @@ -0,0 +1,31 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { exists, query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { hbs } from "ember-cli-htmlbars"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import I18n from "I18n"; + +module( + "Integration | Component | user-menu | chat-notifications-list", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(() => { + pretender.get("/notifications", () => { + return response({ notifications: [] }); + }); + }); + + const template = hbs``; + + test("empty state when there are no notifications", async function (assert) { + await render(template); + assert.ok(exists(".empty-state .empty-state-body")); + assert.strictEqual( + query(".empty-state .empty-state-title").textContent.trim(), + I18n.t("user_menu.no_chat_notifications_title") + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js new file mode 100644 index 00000000000..e05981401b3 --- /dev/null +++ b/plugins/chat/test/javascripts/modifiers/track-message-visibility-test.js @@ -0,0 +1,36 @@ +import { render, waitFor } from "@ember/test-helpers"; +import { exists } from "discourse/tests/helpers/qunit-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; + +module( + "Discourse Chat | Modifier | track-message-visibility", + function (hooks) { + setupRenderingTest(hooks); + + test("Marks message as visible when it intersects with the viewport", async function (assert) { + const template = hbs`
`; + + await render(template); + await waitFor("div[data-visible=true]"); + + assert.ok( + exists("div[data-visible=true]"), + "message is marked as visible" + ); + }); + + test("Marks message as visible when it doesn't intersect with the viewport", async function (assert) { + const template = hbs`
`; + + await render(template); + await waitFor("div[data-visible=false]"); + + assert.ok( + exists("div[data-visible=false]"), + "message is not marked as visible" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js new file mode 100644 index 00000000000..fc974208893 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/helpers/format-chat-date-test.js @@ -0,0 +1,20 @@ +import { module, test } from "qunit"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; + +module("Discourse Chat | Unit | Helpers | format-chat-date", function (hooks) { + setupRenderingTest(hooks); + + test("link to chat message", async function (assert) { + this.set("details", { chat_channel_id: 1 }); + this.set("message", { id: 1 }); + await render(hbs`{{format-chat-date this.message this.details}}`); + + assert.equal( + query(".chat-time").getAttribute("href"), + "/chat/channel/1/-?messageId=1" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js b/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js new file mode 100644 index 00000000000..785841167a6 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/helpers/tonable-emoji-title-test.js @@ -0,0 +1,44 @@ +import { module, test } from "qunit"; +import hbs from "htmlbars-inline-precompile"; +import { render } from "@ember/test-helpers"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module( + "Discourse Chat | Unit | Helpers | tonable-emoji-title", + function (hooks) { + setupRenderingTest(hooks); + + test("When emoji is not tonable", async function (assert) { + this.set("emoji", { name: "foo", tonable: false }); + this.set("diversity", 1); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:" + ); + }); + + test("When emoji is tonable and diversity is 1", async function (assert) { + this.set("emoji", { name: "foo", tonable: true }); + this.set("diversity", 1); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:" + ); + }); + + test("When emoji is tonable and diversity is greater than 1", async function (assert) { + this.set("emoji", { name: "foo", tonable: true }); + this.set("diversity", 2); + await render(hbs`{{tonable-emoji-title this.emoji this.diversity}}`); + + assert.equal( + document.querySelector("#ember-testing").innerText.trim(), + ":foo:t2:" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js b/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js new file mode 100644 index 00000000000..eabb9300e6d --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/chat-composer-buttons-test.js @@ -0,0 +1,38 @@ +import { module, test } from "qunit"; +import { + chatComposerButtons, + chatComposerButtonsDependentKeys, + clearChatComposerButtons, + registerChatComposerButton, +} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; + +module("Discourse Chat | Unit | chat-composer-buttons", function (hooks) { + hooks.beforeEach(function () { + registerChatComposerButton({ + id: "foo", + icon: "times", + dependentKeys: ["test"], + }); + + registerChatComposerButton({ + id: "bar", + translatedLabel() { + return this.baz; + }, + }); + }); + + hooks.afterEach(function () { + clearChatComposerButtons(); + }); + + test("chatComposerButtons", function (assert) { + const button = chatComposerButtons({ baz: "fooz" }, "inline")[1]; + assert.equal(button.id, "bar"); + assert.equal(button.label, "fooz"); + }); + + test("chatComposerButtonsDependentKeys", function (assert) { + assert.deepEqual(chatComposerButtonsDependentKeys(), ["test"]); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js new file mode 100644 index 00000000000..5d29b55a789 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js @@ -0,0 +1,123 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; + +module("Discourse Chat | Unit | chat-emoji-reaction-store", function (hooks) { + hooks.beforeEach(function () { + this.siteSettings = getOwner(this).lookup("service:site-settings"); + this.chatEmojiReactionStore = getOwner(this).lookup( + "service:chat-emoji-reaction-store" + ); + + this.chatEmojiReactionStore.siteSettings = this.siteSettings; + this.chatEmojiReactionStore.reset(); + }); + + hooks.afterEach(function () { + this.chatEmojiReactionStore.reset(); + }); + + // TODO (martin) Remove site setting workarounds after core PR#1290 + test("defaults", function (assert) { + assert.deepEqual( + this.chatEmojiReactionStore.favorites, + (this.siteSettings.default_emoji_reactions || "") + .split("|") + .filter((val) => val) + ); + }); + + test("diversity", function (assert) { + assert.strictEqual(this.chatEmojiReactionStore.diversity, 1); + + this.chatEmojiReactionStore.diversity = 2; + + assert.strictEqual(this.chatEmojiReactionStore.diversity, 2); + }); + + test("#favorites with defaults", function (assert) { + this.siteSettings.default_emoji_reactions = "smile|heart|tada"; + + assert.deepEqual(this.chatEmojiReactionStore.favorites, [ + "smile", + "heart", + "tada", + ]); + }); + + test("#favorites", function (assert) { + this.chatEmojiReactionStore.storedFavorites = ["grinning"]; + + assert.deepEqual(this.chatEmojiReactionStore.favorites, ["grinning"]); + }); + + test("#favorites when tracking multiple times the same emoji", function (assert) { + this.chatEmojiReactionStore.storedFavorites = [ + "grinning", + "yum", + "not_yum", + "yum", + ]; + + assert.deepEqual( + this.chatEmojiReactionStore.favorites, + ["yum", "grinning", "not_yum"], + "it favors count over order" + ); + }); + + test("#favorites when reaching displayed limit", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + [...Array(this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS)].forEach( + (_, index) => { + this.chatEmojiReactionStore.track("yum" + index); + } + ); + this.chatEmojiReactionStore.track("grinning"); + + assert.strictEqual( + this.chatEmojiReactionStore.favorites.length, + this.chatEmojiReactionStore.MAX_DISPLAYED_EMOJIS, + "it enforces the max length" + ); + }); + + test("#storedFavorites", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + this.chatEmojiReactionStore.track("yum"); + + assert.deepEqual(this.chatEmojiReactionStore.storedFavorites, ["yum"]); + }); + + test("#storedFavorites when tracking different emojis", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + this.chatEmojiReactionStore.track("yum"); + this.chatEmojiReactionStore.track("not_yum"); + this.chatEmojiReactionStore.track("yum"); + this.chatEmojiReactionStore.track("grinning"); + + assert.deepEqual( + this.chatEmojiReactionStore.storedFavorites, + ["grinning", "yum", "not_yum", "yum"], + "it ensures last in is first" + ); + }); + + test("#storedFavorites when tracking an emoji after reaching the limit", function (assert) { + this.chatEmojiReactionStore.storedFavorites = []; + [...Array(this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS)].forEach(() => { + this.chatEmojiReactionStore.track("yum"); + }); + this.chatEmojiReactionStore.track("grinning"); + + assert.strictEqual( + this.chatEmojiReactionStore.storedFavorites.length, + this.chatEmojiReactionStore.MAX_TRACKED_EMOJIS, + "it enforces the max length" + ); + assert.strictEqual( + this.chatEmojiReactionStore.storedFavorites.firstObject, + "grinning", + "it correctly stores the last tracked emoji" + ); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js b/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js new file mode 100644 index 00000000000..52bd96830ef --- /dev/null +++ b/plugins/chat/test/javascripts/unit/lib/slugify-channel-test.js @@ -0,0 +1,21 @@ +import { module, test } from "qunit"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +module("Discourse Chat | Unit | slugify-channel", function () { + test("defaults", function (assert) { + assert.equal(slugifyChannel("Foo bar"), "foo-bar"); + }); + + test("a very long name", function (assert) { + const string = + "xAq8l5ca2CtEToeMLe2pEr2VUGQBx3HPlxbkDExKrJHp4f7jCVw9id1EQv1N1lYMRdAIiZNnn94Kr0uU0iiEeVO4XkBVmpW8Mknmd"; + + assert.equal(slugifyChannel(string), string.toLowerCase().slice(0, -1)); + }); + + test("a cyrillic name", function (assert) { + const string = "Русская литература и фольклор"; + + assert.equal(slugifyChannel(string), "русская-литература-и-фольклор"); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js new file mode 100644 index 00000000000..c4a37c337f6 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-channel-info-route-origin-manager-test.js @@ -0,0 +1,45 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; +import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager"; + +module( + "Discourse Chat | Unit | Service | chat-channel-info-route-origin-manager", + function (hooks) { + hooks.beforeEach(function () { + this.manager = getOwner(this).lookup( + "service:chat-channel-info-route-origin-manager" + ); + }); + + hooks.afterEach(function () { + this.manager.origin = null; + }); + + test(".origin", function (assert) { + this.manager.origin = ORIGINS.channnel; + assert.strictEqual(this.manager.origin, ORIGINS.channnel); + }); + + test(".isBrowse", function (assert) { + this.manager.origin = ORIGINS.browse; + assert.strictEqual(this.manager.isBrowse, true); + + this.manager.origin = null; + assert.strictEqual(this.manager.isBrowse, false); + + this.manager.origin = ORIGINS.channel; + assert.strictEqual(this.manager.isBrowse, false); + }); + + test(".isChannel", function (assert) { + this.manager.origin = ORIGINS.channnel; + assert.strictEqual(this.manager.isChannel, true); + + this.manager.origin = ORIGINS.browse; + assert.strictEqual(this.manager.isChannel, false); + + this.manager.origin = null; + assert.strictEqual(this.manager.isChannel, true); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js new file mode 100644 index 00000000000..052a31a683d --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js @@ -0,0 +1,180 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { settled } from "@ember/test-helpers"; + +function emojisReponse() { + return { favorites: [{ name: "sad" }] }; +} + +module( + "Discourse Chat | Unit | Service | chat-emoji-picker-manager", + function (hooks) { + hooks.beforeEach(function () { + pretender.get("/chat/emojis.json", () => { + return [200, {}, emojisReponse()]; + }); + + this.manager = getOwner(this).lookup("service:chat-emoji-picker-manager"); + }); + + hooks.afterEach(function () { + this.manager.close(); + }); + + test("startFromMessageReactionList", async function (assert) { + const callback = () => {}; + this.manager.startFromMessageReactionList({ id: 1 }, false, callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-message"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("startFromMessageActions", async function (assert) { + const callback = () => {}; + this.manager.startFromMessageReactionList({ id: 1 }, false, callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-message"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("addVisibleSections", async function (assert) { + this.manager.addVisibleSections(["favorites", "objects"]); + + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + "objects", + ]); + }); + + test("sections", async function (assert) { + assert.deepEqual(this.manager.sections, []); + + this.manager.startFromComposer(() => {}); + + assert.deepEqual(this.manager.sections, []); + + await settled(); + + assert.deepEqual(this.manager.sections, ["favorites"]); + }); + + test("startFromComposer", async function (assert) { + const callback = () => {}; + this.manager.startFromComposer(callback); + + assert.ok(this.manager.loading); + assert.ok(this.manager.opened); + assert.strictEqual(this.manager.context, "chat-composer"); + assert.strictEqual(this.manager.callback, callback); + assert.deepEqual(this.manager.visibleSections, [ + "favorites", + "smileys_&_emotion", + ]); + assert.strictEqual(this.manager.lastVisibleSection, "favorites"); + + await settled(); + + assert.deepEqual(this.manager.emojis, emojisReponse()); + assert.strictEqual(this.manager.loading, false); + }); + + test("closeExisting", async function (assert) { + const callback = () => { + return; + }; + + this.manager.startFromComposer(() => {}); + this.manager.addVisibleSections("objects"); + this.manager.lastVisibleSection = "objects"; + this.manager.startFromComposer(callback); + + assert.strictEqual( + this.manager.callback, + callback, + "it resets the callback to latest picker" + ); + assert.deepEqual( + this.manager.visibleSections, + ["favorites", "smileys_&_emotion"], + "it resets sections" + ); + assert.strictEqual( + this.manager.lastVisibleSection, + "favorites", + "it resets last visible section" + ); + }); + + test("didSelectEmoji", async function (assert) { + let value; + const callback = (emoji) => { + value = emoji.name; + }; + this.manager.startFromComposer(callback); + this.manager.didSelectEmoji({ name: "joy" }); + + assert.notOk(this.manager.callback); + assert.strictEqual(value, "joy"); + + await settled(); + + assert.notOk(this.manager.opened, "it closes the picker after selection"); + }); + + test("close", async function (assert) { + this.manager.startFromComposer(() => {}); + + assert.ok(this.manager.opened); + assert.ok(this.manager.callback); + + this.manager.addVisibleSections("objects"); + this.manager.lastVisibleSection = "objects"; + this.manager.close(); + + assert.notOk(this.manager.callback); + assert.ok(this.manager.closing); + assert.ok(this.manager.opened); + + await settled(); + + assert.notOk(this.manager.opened); + assert.notOk(this.manager.closing); + assert.deepEqual( + this.manager.visibleSections, + ["favorites", "smileys_&_emotion"], + "it resets visible sections" + ); + assert.strictEqual( + this.manager.lastVisibleSection, + "favorites", + "it resets last visible section" + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js b/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js new file mode 100644 index 00000000000..cc9fb4f171a --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-guardian-test.js @@ -0,0 +1,94 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { set } from "@ember/object"; +import fabricators from "../../helpers/fabricators"; + +acceptance("Discourse Chat | Unit | Service | chat-guardian", function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatGuardian", { + get: () => this.container.lookup("service:chat-guardian"), + }); + Object.defineProperty(this, "siteSettings", { + get: () => this.container.lookup("service:site-settings"), + }); + Object.defineProperty(this, "currentUser", { + get: () => this.container.lookup("service:current-user"), + }); + }); + + needs.user(); + needs.settings(); + + test("#canEditChatChannel", async function (assert) { + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", false); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canEditChatChannel()); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + assert.ok(this.chatGuardian.canEditChatChannel()); + }); + + test("#canUseChat", async function (assert) { + set(this.currentUser, "has_chat_enabled", false); + this.siteSettings.chat_enabled = true; + assert.notOk(this.chatGuardian.canUseChat()); + + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = false; + assert.notOk(this.chatGuardian.canUseChat()); + + set(this.currentUser, "has_chat_enabled", true); + this.siteSettings.chat_enabled = true; + assert.ok(this.chatGuardian.canUseChat()); + }); + + test("#canArchiveChannel", async function (assert) { + const channel = fabricators.chatChannel(); + + set(this.currentUser, "has_chat_enabled", true); + set(this.currentUser, "admin", true); + this.siteSettings.chat_enabled = true; + this.siteSettings.chat_allow_archiving_channels = true; + assert.ok(this.chatGuardian.canArchiveChannel(channel)); + + set(this.currentUser, "admin", false); + set(this.currentUser, "moderator", false); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + set(this.currentUser, "admin", true); + set(this.currentUser, "moderator", true); + + channel.set("status", "read_only"); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + channel.set("status", "open"); + + channel.set("status", "archived"); + assert.notOk(this.chatGuardian.canArchiveChannel(channel)); + channel.set("status", "open"); + }); +}); diff --git a/plugins/chat/test/javascripts/unit/services/chat-test.js b/plugins/chat/test/javascripts/unit/services/chat-test.js new file mode 100644 index 00000000000..ebcb36da953 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-test.js @@ -0,0 +1,263 @@ +import MockPresenceChannel from "../../helpers/mock-presence-channel"; +import { + acceptance, + publishToMessageBus, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import fabricators from "../../helpers/fabricators"; +import { directMessageChannels } from "discourse/plugins/chat/chat-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; +import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; +import sinon from "sinon"; +import pretender from "discourse/tests/helpers/create-pretender"; +import { settled } from "@ember/test-helpers"; + +acceptance("Discourse Chat | Unit | Service | chat", function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + Object.defineProperty(this, "currentUser", { + get: () => this.container.lookup("service:current-user"), + }); + }); + + needs.user({ ignored_users: [] }); + + needs.pretender((server, helper) => { + server.get("/chat/chat_channels.json", () => { + return helper.response({ + public_channels: [ + { + id: 1, + title: "something", + chatable_type: "Category", + last_message_sent_at: "2021-11-08T21:26:05.710Z", + current_user_membership: { + unread_count: 2, + last_read_message_id: 123, + unread_mentions: 0, + muted: false, + }, + }, + ], + direct_message_channels: [], + }); + }); + + server.put("/chat/:chatChannelId/read/:messageId.json", () => { + return helper.response({ success: "OK" }); + }); + }); + + function setupMockPresenceChannel(chatService) { + chatService.set( + "presenceChannel", + MockPresenceChannel.create({ + name: `/chat-reply/1`, + }) + ); + } + + test("#markNetworkAsReliable", async function (assert) { + setupMockPresenceChannel(this.chatService); + + this.chatService.markNetworkAsReliable(); + + assert.strictEqual(this.chatService.isNetworkUnreliable, false); + }); + + test("#markNetworkAsUnreliable", async function (assert) { + setupMockPresenceChannel(this.chatService); + this.chatService.markNetworkAsUnreliable(); + + assert.strictEqual(this.chatService.isNetworkUnreliable, true); + + await settled(); + + assert.strictEqual( + this.chatService.isNetworkUnreliable, + false, + "it resets state after a delay" + ); + }); + + test("#startTrackingChannel - sorts dm channels", async function (assert) { + setupMockPresenceChannel(this.chatService); + const fixtures = cloneJSON(directMessageChannels).mapBy("chat_channel"); + const channel1 = ChatChannel.create(fixtures[0]); + const channel2 = ChatChannel.create(fixtures[1]); + await this.chatService.startTrackingChannel(channel1); + this.currentUser.set( + `chat_channel_tracking_state.${channel1.id}.unread_count`, + 0 + ); + await this.chatService.startTrackingChannel(channel2); + + assert.strictEqual( + this.chatService.directMessageChannels.firstObject.title, + channel2.title + ); + }); + + test("#refreshTrackingState", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + + await this.chatService.refreshTrackingState(); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2 + ); + }); + + test("attempts to track a non followed channel", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + const channel = fabricators.chatChannel(); + await this.chatService.startTrackingChannel(channel); + + assert.false(channel.current_user_membership.following); + assert.notOk( + this.currentUser.chat_channel_tracking_state[channel.id], + "it doesn’t track it" + ); + }); + + test("/chat/:channelId/new-messages - message from current user", async function (assert) { + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: this.currentUser.id, + username: this.currentUser.username, + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 124, + "updates tracking state last message id to the message id sent by current user" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2, + "does not increment unread count" + ); + }); + + test("/chat/:channelId/new-messages - message from user that current user is ignoring", async function (assert) { + this.currentUser.set("ignored_users", ["johnny"]); + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: 2327, + username: "johnny", + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 124, + "updates tracking state last message id to the message id sent by johnny" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 2, + "does not increment unread count" + ); + }); + + test("/chat/:channelId/new-messages - message from another user", async function (assert) { + setupMockPresenceChannel(this.chatService); + await this.chatService.forceRefreshChannels(); + + await publishToMessageBus("/chat/1/new-messages", { + user_id: 2327, + username: "jane", + message_id: 124, + }); + + assert.equal( + this.currentUser.chat_channel_tracking_state[1].chat_message_id, + 123, + "does not update tracking state last message id to the message id sent by jane" + ); + assert.equal( + this.currentUser.chat_channel_tracking_state[1].unread_count, + 3, + "does increment unread count" + ); + }); + + test("#updateLastReadMessage - updates and tracks the last read message", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 2 } }]; + }); + const activeChannel = fabricators.chatChannel({ + current_user_membership: { last_read_message_id: 1, following: true }, + }); + this.chatService.setActiveChannel(activeChannel); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(activeChannel.lastSendReadMessageId, 2); + }); + + test("#updateLastReadMessage - does nothing if the user doesn't follow the channel", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + this.chatService.setActiveChannel( + fabricators.chatChannel({ current_user_membership: { following: false } }) + ); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 1 } }]; + }); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(this.chatService.activeChannel.lastSendReadMessageId, null); + }); + + test("#updateLastReadMessage - does nothing if the user already read the message", async function (assert) { + this.currentUser.set("chat_channel_tracking_state", {}); + sinon.stub(document, "querySelectorAll").callsFake(function () { + return [{ dataset: { id: 1 } }]; + }); + const activeChannel = fabricators.chatChannel({ + current_user_membership: { last_read_message_id: 2, following: true }, + }); + this.chatService.setActiveChannel(activeChannel); + + this.chatService.updateLastReadMessage(); + await settled(); + + assert.equal(activeChannel.lastSendReadMessageId, 2); + }); +}); + +acceptance( + "Discourse Chat | Unit | Service | chat - no current user", + function (needs) { + needs.hooks.beforeEach(function () { + Object.defineProperty(this, "chatService", { + get: () => this.container.lookup("service:chat"), + }); + }); + + test("#refreshTrackingState", async function (assert) { + pretender.get(`/chat/chat_channels.json`, () => { + assert.step("unexpected"); + return [200, { "Content-Type": "application/json" }, {}]; + }); + + assert.step("start"); + await this.chatService.refreshTrackingState(); + assert.step("end"); + + assert.verifySteps(["start", "end"], "it does no requests"); + }); + } +); diff --git a/plugins/chat/test/javascripts/unit/services/full-page-chat-test.js b/plugins/chat/test/javascripts/unit/services/full-page-chat-test.js new file mode 100644 index 00000000000..19cd3809592 --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/full-page-chat-test.js @@ -0,0 +1,44 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; + +module("Discourse Chat | Unit | Service | full-page-chat", function (hooks) { + hooks.beforeEach(function () { + this.fullPageChat = getOwner(this).lookup("service:full-page-chat"); + }); + + hooks.afterEach(function () { + this.fullPageChat.exit(); + }); + + test("defaults", function (assert) { + assert.strictEqual(this.fullPageChat.isActive, false); + }); + + test("enter", function (assert) { + this.fullPageChat.enter(); + assert.strictEqual(this.fullPageChat.isActive, true); + }); + + test("exit", function (assert) { + this.fullPageChat.enter(); + assert.strictEqual(this.fullPageChat.isActive, true); + this.fullPageChat.exit(); + assert.strictEqual(this.fullPageChat.isActive, false); + }); + + test("isPreferred", function (assert) { + assert.strictEqual(this.fullPageChat.isPreferred, false); + this.fullPageChat.isPreferred = true; + assert.strictEqual(this.fullPageChat.isPreferred, true); + }); + + test("previous route", function (assert) { + const name = "foo"; + const params = { id: 1, slug: "bar" }; + this.fullPageChat.enter({ name, params }); + const routeInfo = this.fullPageChat.exit(); + + assert.strictEqual(routeInfo.name, name); + assert.deepEqual(routeInfo.params, params); + }); +}); diff --git a/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js new file mode 100644 index 00000000000..a5f561b2a0f --- /dev/null +++ b/plugins/chat/test/javascripts/widgets/chat-invitation-notification-item-test.js @@ -0,0 +1,52 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { deepMerge } from "discourse-common/lib/object"; +import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; +import Notification from "discourse/models/notification"; +import hbs from "htmlbars-inline-precompile"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; + +function getNotification(overrides = {}) { + return Notification.create( + deepMerge( + { + id: 11, + notification_type: NOTIFICATION_TYPES.chat_invitation, + read: false, + data: { + message: "chat.invitation_notification", + invited_by_username: "eviltrout", + chat_channel_id: 9, + chat_message_id: 2, + chat_channel_title: "Site", + }, + }, + overrides + ) + ); +} + +module( + "Discourse Chat | Widget | chat-invitation-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("notification url", async function (assert) { + this.set("args", getNotification()); + + await render( + hbs`` + ); + + const data = this.args.data; + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel( + data.chat_channel_title + )}?messageId=${data.chat_message_id}` + ); + }); + } +); diff --git a/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js b/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js new file mode 100644 index 00000000000..d341e4ac8ce --- /dev/null +++ b/plugins/chat/test/javascripts/widgets/chat-mention-notification-item-test.js @@ -0,0 +1,139 @@ +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import { render } from "@ember/test-helpers"; +import { deepMerge } from "discourse-common/lib/object"; +import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; +import Notification from "discourse/models/notification"; +import hbs from "htmlbars-inline-precompile"; +import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel"; +import I18n from "I18n"; + +function getNotification(overrides = {}) { + return Notification.create( + deepMerge( + { + id: 11, + notification_type: NOTIFICATION_TYPES.chat_invitation, + read: false, + data: { + message: "chat.mention_notification", + mentioned_by_username: "eviltrout", + chat_channel_id: 9, + chat_message_id: 2, + chat_channel_title: "Site", + }, + }, + overrides + ) + ); +} + +module( + "Discourse Chat | Widget | chat-mention-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set("args", getNotification()); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.direct_html", { + username: "eviltrout", + identifier: null, + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel( + data.chat_channel_title + )}?messageId=${data.chat_message_id}` + ); + }); + } +); + +module( + "Discourse Chat | Widget | chat-group-mention-notification-item", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set( + "args", + getNotification({ + data: { + mentioned_by_username: "eviltrout", + identifier: "moderators", + }, + }) + ); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.other_html", { + username: "eviltrout", + identifier: "@moderators", + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel( + data.chat_channel_title + )}?messageId=${data.chat_message_id}` + ); + }); + } +); + +module( + "Discourse Chat | Widget | chat-group-mention-notification-item (@all)", + function (hooks) { + setupRenderingTest(hooks); + + test("generated link", async function (assert) { + this.set( + "args", + getNotification({ + data: { + mentioned_by_username: "eviltrout", + identifier: "all", + }, + }) + ); + const data = this.args.data; + await render( + hbs`` + ); + + assert.strictEqual( + query(".chat-invitation a div").innerHTML.trim(), + I18n.t("notifications.popup.chat_mention.other_html", { + username: "eviltrout", + identifier: "@all", + channel: "Site", + }) + ); + + assert.strictEqual( + query(".chat-invitation a").getAttribute("href"), + `/chat/channel/${data.chat_channel_id}/${slugifyChannel( + data.chat_channel_title + )}?messageId=${data.chat_message_id}` + ); + }); + } +);