DEV: Move `discourse-chat` to the core repo. (#18776)

As part of this move, we are also renaming `discourse-chat` to `chat`.
This commit is contained in:
Roman Rizzi 2022-11-02 10:41:30 -03:00 committed by GitHub
parent e7e24843dc
commit 0a5f548635
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
697 changed files with 61642 additions and 40 deletions

1
.gitignore vendored
View File

@ -39,6 +39,7 @@
!/plugins/discourse-narrative-bot
!/plugins/discourse-presence
!/plugins/lazy-yt/
!/plugins/chat/
!/plugins/poll/
!/plugins/styleguide
/plugins/*/auto_generated/

View File

@ -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
#

View File

@ -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",

54
plugins/chat/README.md Normal file
View File

@ -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 buttons 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"]`

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
#

View File

@ -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)
#

View File

@ -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
#

View File

@ -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
#

View File

@ -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?) ? "<p>#{uploads.first.original_filename}</p>" : 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)
#

View File

@ -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
#

View File

@ -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)
#

View File

@ -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
#

View File

@ -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

View File

@ -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
#

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
#

View File

@ -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
#

View File

@ -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)
#

View File

@ -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

View File

@ -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
#

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class ChatChannelSearchSerializer < StructuredChannelSerializer
has_many :users, serializer: BasicUserSerializer, embed: :objects
def users
object[:users]
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class ChatWebhookEventSerializer < ApplicationSerializer
attributes :username, :emoji
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,10 @@
<% if @chat_email_frequencies %>
<p>
<label><%= t 'unsubscribe.chat_summary.select_title' %></label>
<%=
select_tag :chat_email_frequency,
options_for_select(@chat_email_frequencies, @current_chat_email_frequency),
class: 'combobox'
%>
</p>
<% end %>

View File

@ -0,0 +1,84 @@
<div class="summary-email">
<table class="chat-summary-header text-header with-dir" style="background-color:#<%= @header_bgcolor -%>;width:100%;min-width:100%;">
<tr>
<td align="center" style="text-align: center;padding: 20px 0; font-family:Arial,sans-serif;">
<a href="<%= Discourse.base_url %>" style="color:#<%= @header_color -%>;font-size:22px;text-decoration:none;">
<%- if logo_url.blank? %>
<%= SiteSetting.title %>
<%- else %>
<img src="<%= logo_url %>" height="40" style="clear:both;display:block;height:40px;margin:auto;max-width:100%;outline:0;text-decoration:none;" alt="<%= SiteSetting.title %>">
<%- end %>
</a>
</td>
</tr>
<tr>
<td align="center" style="font-weight:bold;font-size:22px;color:#0a0a0a">
<%= I18n.t("user_notifications.chat_summary.description", count: @messages.size) %>
</td>
</tr>
</table>
<%- @grouped_messages.each do |chat_channel, messages| %>
<%- other_messages_count = messages.size - 2 %>
<table class="chat-summary-content" style="padding:1em;margin-top:20px;width:100%;min-width:100%;background-color:#f7f7f7;">
<tbody>
<tr>
<td colspan="100%">
<h5 style="margin:0.5em 0 0.5em 0;font-size:0.9em;"><%= chat_channel.title(@user) %></h5>
</td>
</tr>
<%- messages.take(2).each do |chat_message| %>
<%- sender = chat_message.user %>
<%- sender_name = @display_usernames ? sender.username : sender.name %>
<tr class="message-row">
<td style="white-space:nowrap;vertical-align:top;padding:<%= rtl? ? '1em 2em 0 0' : '1em 0 0 2em' %>">
<img src="<%= sender.small_avatar_url -%>" style="height:20px;width:20px;margin:<%= rtl? ? '0 0 5px 0' : '0 5px 0 0' %>;border-radius:50%;clear:both;display:inline-block;outline:0;text-decoration:none;vertical-align:top;" alt="<%= sender_name -%>">
<span style="display:inline-block;color:#0a0a0a;vertical-align:top;font-weight:bold;">
<%= sender_name -%>
</span>
<span style="display:inline-block;color:#0a0a0a;font-size:0.8em;">
<%= I18n.l(@user_tz.to_local(chat_message.created_at), format: :long) -%>
</span>
</td>
</tr>
<tr>
<td style="width:99%;margin:0;padding:<%= rtl? ? '0 2em 0 0' : '0 0 0 2em' %>;vertical-align:top;">
<%= email_excerpt(chat_message.cooked_for_excerpt) %>
</td>
</tr>
<%- end %>
<tr>
<td colspan="100%" style="padding:<%= rtl? ? '2em 2em 0 0' : '2em 0 0 2em' %>">
<a class="more-messages-link" href="<%= messages.first.full_url %>">
<%- 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 %>
</a>
</td>
</tr>
</tbody>
</table>
<%- end %>
</div>
<table class='summary-footer with-dir' style="margin-top:2em;width:100%">
<tr>
<td align="center">
<%- if @unsubscribe_link %>
<%= raw(t 'user_notifications.chat_summary.unsubscribe',
site_link: html_site_link,
email_preferences_link: link_to(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path),
unsubscribe_link: link_to(t('user_notifications.digest.click_here'), @unsubscribe_link)) %>
<%- else %>
<%= raw(t 'user_notifications.chat_summary.unsubscribe_no_link',
site_link: html_site_link,
email_preferences_link: link_to(t('user_notifications.chat_summary.your_chat_settings'), @preferences_path)) %>
<%- end %>
</td>
</tr>
</table>

View File

@ -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) %>

View File

@ -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";
}
}

View File

@ -0,0 +1,7 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("chat");
},
};

View File

@ -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" });
});
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}

View File

@ -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);
});
}
}

View File

@ -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,
})
);
},
});

View File

@ -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}`);
},
});

View File

@ -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();
}
}

View File

@ -0,0 +1,3 @@
import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header";
export default ComboBoxSelectBoxHeaderComponent.extend({});

View File

@ -0,0 +1,5 @@
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
export default SelectKitRowComponent.extend({
classNames: ["chat-channel-chooser-row"],
});

View File

@ -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";
},
});

View File

@ -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,
})
);
},
});

View File

@ -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";
}
},
});

View File

@ -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();
});
}
}

View File

@ -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);
});
}
}

View File

@ -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");
}
},
});

View File

@ -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();
}
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -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),
});
}
}

View File

@ -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;
}
},
});

View File

@ -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}`);
}
}

View File

@ -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);
});
}
}

View File

@ -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;
},
});

View File

@ -0,0 +1,7 @@
import Component from "@ember/component";
export default class ChatComposerDropdown extends Component {
tagName = "";
buttons = null;
isDisabled = false;
}

View File

@ -0,0 +1,5 @@
import Component from "@ember/component";
export default class ChatComposerInlineButtons extends Component {
tagName = "";
}

View File

@ -0,0 +1,5 @@
import Component from "@ember/component";
export default Component.extend({
tagName: "",
});

View File

@ -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;
},
});

View File

@ -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 });
}
},
});

View File

@ -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 shouldnt
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
});
},
});

View File

@ -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,
})
);
}
});
}
}

Some files were not shown because too many files have changed in this diff Show More