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:
parent
e7e24843dc
commit
0a5f548635
|
@ -39,6 +39,7 @@
|
|||
!/plugins/discourse-narrative-bot
|
||||
!/plugins/discourse-presence
|
||||
!/plugins/lazy-yt/
|
||||
!/plugins/chat/
|
||||
!/plugins/poll/
|
||||
!/plugins/styleguide
|
||||
/plugins/*/auto_generated/
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
:warning: This plugin is still in active development and may change frequently
|
||||
|
||||
## Documentation
|
||||
|
||||
The Discourse Chat plugin adds chat functionality to your Discourse so it can natively support both long-form and short-form communication needs of your online community.
|
||||
|
||||
For documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881)
|
||||
|
||||
## Plugin API
|
||||
|
||||
### registerChatComposerButton
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
api.registerChatComposerButton({ id: "foo", ... });
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
Every option accepts a `value` or a `function`, when passing a function `this` will be the `chat-composer` component instance. Example of an option using a function:
|
||||
|
||||
```javascript
|
||||
api.registerChatComposerButton({
|
||||
id: "foo",
|
||||
displayed() {
|
||||
return this.site.mobileView && this.canAttachUploads;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
##### Required
|
||||
|
||||
- `id` unique, used to identify your button, eg: "gifs"
|
||||
- `action` callback when the button is pressed, can be an action name or an anonymous function, eg: "onFooClicked" or `() => { console.log("clicked") }`
|
||||
|
||||
A button requires at least an icon or a label:
|
||||
|
||||
- `icon`, eg: "times"
|
||||
- `label`, text displayed on the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedLabel`, text displayed on the button, a string, eg: "Add gifs"
|
||||
|
||||
##### Optional
|
||||
|
||||
- `position`, can be "inline" or "dropdown", defaults to "inline"
|
||||
- `title`, title attribute of the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedTitle`, title attribute of the button, a string, eg: "Add gifs"
|
||||
- `ariaLabel`, aria-label attribute of the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedAriaLabel`, aria-label attribute of the button, a string, eg: "Add gifs"
|
||||
- `classNames`, additional names to add to the button’s class attribute, eg: ["foo", "bar"]
|
||||
- `displayed`, hide/or show the button, expects a boolean
|
||||
- `disabled`, sets the disabled attribute on the button, expects a boolean
|
||||
- `priority`, an integer defining the order of the buttons, higher comes first, eg: `700`
|
||||
- `dependentKeys`, list of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
#
|
|
@ -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)
|
||||
#
|
|
@ -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
|
||||
#
|
|
@ -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
|
||||
#
|
|
@ -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)
|
||||
#
|
|
@ -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
|
||||
#
|
|
@ -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)
|
||||
#
|
|
@ -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
|
||||
#
|
|
@ -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
|
|
@ -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
|
||||
#
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
#
|
|
@ -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
|
||||
#
|
|
@ -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)
|
||||
#
|
|
@ -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
|
|
@ -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
|
||||
#
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChatChannelSearchSerializer < StructuredChannelSerializer
|
||||
has_many :users, serializer: BasicUserSerializer, embed: :objects
|
||||
|
||||
def users
|
||||
object[:users]
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChatWebhookEventSerializer < ApplicationSerializer
|
||||
attributes :username, :emoji
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 %>
|
|
@ -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>
|
|
@ -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) %>
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
resource: "admin.adminPlugins",
|
||||
path: "/plugins",
|
||||
map() {
|
||||
this.route("chat");
|
||||
},
|
||||
};
|
|
@ -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" });
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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}`);
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header";
|
||||
|
||||
export default ComboBoxSelectBoxHeaderComponent.extend({});
|
|
@ -0,0 +1,5 @@
|
|||
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
|
||||
|
||||
export default SelectKitRowComponent.extend({
|
||||
classNames: ["chat-channel-chooser-row"],
|
||||
});
|
|
@ -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";
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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";
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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}`);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default class ChatComposerDropdown extends Component {
|
||||
tagName = "";
|
||||
buttons = null;
|
||||
isDisabled = false;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default class ChatComposerInlineButtons extends Component {
|
||||
tagName = "";
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import Component from "@ember/component";
|
||||
|
||||
export default Component.extend({
|
||||
tagName: "",
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
|
@ -0,0 +1,732 @@
|
|||
import { isEmpty } from "@ember/utils";
|
||||
import Component from "@ember/component";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import discourseComputed, {
|
||||
afterRender,
|
||||
bind,
|
||||
} from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel, next, schedule, throttle } from "@ember/runloop";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { readOnly, reads } from "@ember/object/computed";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import { Promise } from "rsvp";
|
||||
import { translations } from "pretty-text/emoji/data";
|
||||
import { channelStatusName } from "discourse/plugins/chat/discourse/models/chat-channel";
|
||||
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
|
||||
import {
|
||||
chatComposerButtons,
|
||||
chatComposerButtonsDependentKeys,
|
||||
} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
|
||||
const THROTTLE_MS = 150;
|
||||
|
||||
export default Component.extend(TextareaTextManipulation, {
|
||||
chatChannel: null,
|
||||
lastChatChannelId: null,
|
||||
chat: service(),
|
||||
classNames: ["chat-composer-container"],
|
||||
classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
|
||||
userSilenced: readOnly("details.user_silenced"),
|
||||
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
|
||||
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
|
||||
editingMessage: null,
|
||||
fullPage: false,
|
||||
onValueChange: null,
|
||||
timer: null,
|
||||
value: "",
|
||||
inProgressUploads: null,
|
||||
composerEventPrefix: "chat",
|
||||
composerFocusSelector: ".chat-composer-input",
|
||||
canAttachUploads: reads("siteSettings.chat_allow_uploads"),
|
||||
isNetworkUnreliable: reads("chat.isNetworkUnreliable"),
|
||||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
inlineButtons() {
|
||||
return chatComposerButtons(this, "inline");
|
||||
},
|
||||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
dropdownButtons() {
|
||||
return chatComposerButtons(this, "dropdown");
|
||||
},
|
||||
|
||||
@discourseComputed("chatEmojiPickerManager.{opened,context}")
|
||||
emojiPickerVisible(picker) {
|
||||
return picker.opened && picker.context === "chat-composer";
|
||||
},
|
||||
|
||||
@discourseComputed("fullPage")
|
||||
fileUploadElementId(fullPage) {
|
||||
return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader";
|
||||
},
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged");
|
||||
this.appEvents.on(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
"_inProgressUploadsChanged"
|
||||
);
|
||||
|
||||
this.setProperties({
|
||||
inProgressUploads: [],
|
||||
_uploads: [],
|
||||
});
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this._textarea = this.element.querySelector(".chat-composer-input");
|
||||
this._$textarea = $(this._textarea);
|
||||
this._applyCategoryHashtagAutocomplete(this._$textarea);
|
||||
this._applyEmojiAutocomplete(this._$textarea);
|
||||
this.appEvents.on("chat:focus-composer", this, "_focusTextArea");
|
||||
this.appEvents.on("chat:insert-text", this, "insertText");
|
||||
this._focusTextArea();
|
||||
|
||||
this.appEvents.on("chat:modify-selection", this, "_modifySelection");
|
||||
this.appEvents.on(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"_openInsertLinkModal"
|
||||
);
|
||||
document.addEventListener("visibilitychange", this._blurInput);
|
||||
document.addEventListener("resume", this._blurInput);
|
||||
document.addEventListener("freeze", this._blurInput);
|
||||
|
||||
this.set("ready", true);
|
||||
},
|
||||
|
||||
_modifySelection(opts = { type: null }) {
|
||||
const sel = this.getSelected("", { lineVal: true });
|
||||
if (opts.type === "bold") {
|
||||
this.applySurround(sel, "**", "**", "bold_text");
|
||||
} else if (opts.type === "italic") {
|
||||
this.applySurround(sel, "_", "_", "italic_text");
|
||||
} else if (opts.type === "code") {
|
||||
this.applySurround(sel, "`", "`", "code_text");
|
||||
}
|
||||
},
|
||||
|
||||
_openInsertLinkModal() {
|
||||
const selected = this.getSelected("", { lineVal: true });
|
||||
const linkText = selected?.value;
|
||||
showModal("insert-hyperlink").setProperties({
|
||||
linkText,
|
||||
toolbarEvent: {
|
||||
addText: (text) => this.addText(selected, text),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.off(
|
||||
"chat-composer:reply-to-set",
|
||||
this,
|
||||
"_replyToMsgChanged"
|
||||
);
|
||||
this.appEvents.off(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
"_inProgressUploadsChanged"
|
||||
);
|
||||
|
||||
if (this.timer) {
|
||||
cancel(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
this.appEvents.off("chat:focus-composer", this, "_focusTextArea");
|
||||
this.appEvents.off("chat:insert-text", this, "insertText");
|
||||
this.appEvents.off("chat:modify-selection", this, "_modifySelection");
|
||||
this.appEvents.off(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"_openInsertLinkModal"
|
||||
);
|
||||
document.removeEventListener("visibilitychange", this._blurInput);
|
||||
document.removeEventListener("resume", this._blurInput);
|
||||
document.removeEventListener("freeze", this._blurInput);
|
||||
},
|
||||
|
||||
// It is important that this is keyDown and not keyUp, otherwise
|
||||
// we add new lines to chat message on send and on edit, because
|
||||
// you cannot prevent default with a keyUp event -- it is like trying
|
||||
// to shut the gate after the horse has already bolted!
|
||||
keyDown(event) {
|
||||
if (this.site.mobileView || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// keyCode for 'Enter'
|
||||
if (event.keyCode === 13) {
|
||||
if (event.shiftKey) {
|
||||
// Shift+Enter: insert newline
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Enter, plain Enter: send
|
||||
if (!event.ctrlKey) {
|
||||
// if we are inside a code block just insert newline
|
||||
const { pre } = this.getSelected(null, { lineVal: true });
|
||||
if (this.isInside(pre, /(^|\n)```/g)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.sendClicked();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" &&
|
||||
this._messageIsEmpty() &&
|
||||
!this.editingMessage
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.onEditLastMessageRequested();
|
||||
}
|
||||
|
||||
if (event.keyCode === 27) {
|
||||
// keyCode for 'Escape'
|
||||
if (this.replyToMsg) {
|
||||
this.set("value", "");
|
||||
this._replyToMsgChanged(null);
|
||||
return false;
|
||||
} else if (this.editingMessage) {
|
||||
this.set("value", "");
|
||||
this.cancelEditing();
|
||||
return false;
|
||||
} else {
|
||||
this._textarea.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
|
||||
if (
|
||||
!this.editingMessage &&
|
||||
this.draft &&
|
||||
this.chatChannel?.canModifyMessages(this.currentUser)
|
||||
) {
|
||||
// uses uploads from draft here...
|
||||
this.setProperties({
|
||||
value: this.draft.value,
|
||||
replyToMsg: this.draft.replyToMsg,
|
||||
});
|
||||
|
||||
this._syncUploads(this.draft.uploads);
|
||||
this.setInReplyToMsg(this.draft.replyToMsg);
|
||||
}
|
||||
|
||||
if (this.editingMessage && !this.loading) {
|
||||
this.setProperties({
|
||||
replyToMsg: null,
|
||||
value: this.editingMessage.message,
|
||||
});
|
||||
|
||||
this._syncUploads(this.editingMessage.uploads);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
|
||||
}
|
||||
|
||||
this.set("lastChatChannelId", this.chatChannel.id);
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
// the chat-composer needs to be able to set the internal list of uploads
|
||||
// for chat-composer-uploads to preload in existing uploads for drafts
|
||||
// and for when messages are being edited.
|
||||
//
|
||||
// the opposite is true as well -- when an upload is completed the chat-composer
|
||||
// needs its internal state updated so drafts can be saved, which is handled
|
||||
// by the uploadsChanged action
|
||||
_syncUploads(newUploads = []) {
|
||||
const currentUploadIds = this._uploads.mapBy("id");
|
||||
const newUploadIds = newUploads.mapBy("id");
|
||||
|
||||
// don't need to load the uploads into chat-composer-uploads if
|
||||
// nothing has changed otherwise we would rerender for no reason
|
||||
if (
|
||||
currentUploadIds.length === newUploadIds.length &&
|
||||
newUploadIds.every((newUploadId) =>
|
||||
currentUploadIds.includes(newUploadId)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("_uploads", cloneJSON(newUploads));
|
||||
this.appEvents.trigger("chat-composer:load-uploads", this._uploads);
|
||||
},
|
||||
|
||||
_inProgressUploadsChanged(inProgressUploads) {
|
||||
next(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("inProgressUploads", inProgressUploads);
|
||||
});
|
||||
},
|
||||
|
||||
_replyToMsgChanged(replyToMsg) {
|
||||
this.set("replyToMsg", replyToMsg);
|
||||
this.onValueChange?.(this.value, this._uploads, replyToMsg);
|
||||
},
|
||||
|
||||
@action
|
||||
onTextareaInput(value) {
|
||||
this.set("value", value);
|
||||
this.resizeTextarea();
|
||||
|
||||
// throttle, not debounce, because we do eventually want to react during the typing
|
||||
this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS);
|
||||
},
|
||||
|
||||
@bind
|
||||
_handleTextareaInput() {
|
||||
this._applyUserAutocomplete();
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
},
|
||||
|
||||
@bind
|
||||
_blurInput() {
|
||||
document.activeElement?.blur();
|
||||
},
|
||||
|
||||
@action
|
||||
uploadClicked() {
|
||||
this.element.querySelector(`#${this.fileUploadElementId}`).click();
|
||||
},
|
||||
|
||||
@bind
|
||||
didSelectEmoji(emoji) {
|
||||
const code = `:${emoji}:`;
|
||||
this.chatEmojiReactionStore.track(code);
|
||||
this.addText(this.getSelected(), code);
|
||||
},
|
||||
|
||||
@action
|
||||
insertDiscourseLocalDate() {
|
||||
showModal("discourse-local-dates-create-modal").setProperties({
|
||||
insertDate: (markup) => {
|
||||
this.addText(this.getSelected(), markup);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// text-area-manipulation mixin override
|
||||
addText() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
_applyUserAutocomplete() {
|
||||
if (this.siteSettings.enable_mentions) {
|
||||
$(this._textarea).autocomplete({
|
||||
template: findRawTemplate("user-selector-autocomplete"),
|
||||
key: "@",
|
||||
width: "100%",
|
||||
treatAsTextarea: true,
|
||||
autoSelectFirstSuggestion: true,
|
||||
transformComplete: (v) => v.username || v.name,
|
||||
dataSource: (term) => userSearch({ term, includeGroups: true }),
|
||||
afterComplete: (text) => {
|
||||
this.set("value", text);
|
||||
this._focusTextArea();
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_applyCategoryHashtagAutocomplete($textarea) {
|
||||
setupHashtagAutocomplete(
|
||||
"chat-composer",
|
||||
$textarea,
|
||||
this.siteSettings,
|
||||
(value) => {
|
||||
this.set("value", value);
|
||||
return this._focusTextArea();
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_applyEmojiAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
$textarea.autocomplete({
|
||||
template: findRawTemplate("emoji-selector-autocomplete"),
|
||||
key: ":",
|
||||
afterComplete: (text) => {
|
||||
this.set("value", text);
|
||||
this._focusTextArea();
|
||||
},
|
||||
treatAsTextarea: true,
|
||||
|
||||
onKeyUp: (text, cp) => {
|
||||
const matches =
|
||||
/(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
|
||||
text.substring(0, cp)
|
||||
);
|
||||
|
||||
if (matches && matches[1]) {
|
||||
return [matches[1]];
|
||||
}
|
||||
},
|
||||
|
||||
transformComplete: (v) => {
|
||||
if (v.code) {
|
||||
this.chatEmojiReactionStore.track(v.code);
|
||||
return `${v.code}:`;
|
||||
} else {
|
||||
$textarea.autocomplete({ cancel: true });
|
||||
this.set("emojiPickerIsActive", true);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
dataSource: (term) => {
|
||||
return new Promise((resolve) => {
|
||||
const full = `:${term}`;
|
||||
term = term.toLowerCase();
|
||||
|
||||
// We need to avoid quick emoji autocomplete cause it can interfere with quick
|
||||
// typing, set minimal length to 2
|
||||
let minLength = Math.max(
|
||||
this.siteSettings.emoji_autocomplete_min_chars,
|
||||
2
|
||||
);
|
||||
|
||||
if (term.length < minLength) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
// bypass :-p and other common typed smileys
|
||||
if (
|
||||
!term.match(
|
||||
/[^-\{\}\[\]\(\)\*_\<\>\\\/].*[^-\{\}\[\]\(\)\*_\<\>\\\/]/
|
||||
)
|
||||
) {
|
||||
return resolve(SKIP);
|
||||
}
|
||||
|
||||
if (term === "") {
|
||||
if (this.chatEmojiReactionStore.favorites.length) {
|
||||
return resolve(this.chatEmojiReactionStore.favorites.slice(0, 5));
|
||||
} else {
|
||||
return resolve([
|
||||
"slight_smile",
|
||||
"smile",
|
||||
"wink",
|
||||
"sunny",
|
||||
"blush",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// note this will only work for emojis starting with :
|
||||
// eg: :-)
|
||||
const emojiTranslation =
|
||||
this.get("site.custom_emoji_translation") || {};
|
||||
const allTranslations = Object.assign(
|
||||
{},
|
||||
translations,
|
||||
emojiTranslation
|
||||
);
|
||||
if (allTranslations[full]) {
|
||||
return resolve([allTranslations[full]]);
|
||||
}
|
||||
|
||||
const match = term.match(/^:?(.*?):t([2-6])?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const scale = match[2];
|
||||
|
||||
if (isSkinTonableEmoji(name)) {
|
||||
if (scale) {
|
||||
return resolve([`${name}:t${scale}`]);
|
||||
} else {
|
||||
return resolve([2, 3, 4, 5, 6].map((x) => `${name}:t${x}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = emojiSearch(term, {
|
||||
maxResults: 5,
|
||||
diversity: this.chatEmojiReactionStore.diversity,
|
||||
});
|
||||
|
||||
return resolve(options);
|
||||
})
|
||||
.then((list) => {
|
||||
if (list === SKIP) {
|
||||
return;
|
||||
}
|
||||
return list.map((code) => ({ code, src: emojiUrlFor(code) }));
|
||||
})
|
||||
.then((list) => {
|
||||
if (list?.length) {
|
||||
list.push({ label: I18n.t("composer.more_emoji"), term });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@afterRender
|
||||
_focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) {
|
||||
if (this.chatChannel.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.resizeTextarea) {
|
||||
this.resizeTextarea();
|
||||
}
|
||||
|
||||
if (opts.ensureAtEnd) {
|
||||
this._textarea.setSelectionRange(this.value.length, this.value.length);
|
||||
}
|
||||
|
||||
if (this.capabilities.isIpadOS || this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
schedule("afterRender", () => {
|
||||
this._textarea?.focus();
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
onEmojiSelected(code) {
|
||||
this.emojiSelected(code);
|
||||
this.set("emojiPickerIsActive", false);
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"chatChannel.{id,chatable.users.[]}",
|
||||
"canInteractWithChat"
|
||||
)
|
||||
disableComposer(channel, canInteractWithChat) {
|
||||
return (
|
||||
(channel.isDraft && isEmpty(channel?.chatable?.users)) ||
|
||||
!canInteractWithChat ||
|
||||
!channel.canModifyMessages(this.currentUser)
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}")
|
||||
placeholder(userSilenced, chatChannel) {
|
||||
if (!chatChannel.canModifyMessages(this.currentUser)) {
|
||||
return I18n.t("chat.placeholder_new_message_disallowed", {
|
||||
status: channelStatusName(chatChannel.status).toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
if (chatChannel.isDraft) {
|
||||
return I18n.t("chat.placeholder_start_conversation", {
|
||||
usernames: chatChannel?.chatable?.users?.length
|
||||
? chatChannel.chatable.users.mapBy("username").join(", ")
|
||||
: "...",
|
||||
});
|
||||
}
|
||||
|
||||
if (userSilenced) {
|
||||
return I18n.t("chat.placeholder_silenced");
|
||||
} else {
|
||||
return this.messageRecipient(chatChannel);
|
||||
}
|
||||
},
|
||||
|
||||
messageRecipient(chatChannel) {
|
||||
if (chatChannel.isDirectMessageChannel) {
|
||||
const directMessageRecipients = chatChannel.chatable.users;
|
||||
if (
|
||||
directMessageRecipients.length === 1 &&
|
||||
directMessageRecipients[0].id === this.currentUser.id
|
||||
) {
|
||||
return I18n.t("chat.placeholder_self");
|
||||
}
|
||||
|
||||
return I18n.t("chat.placeholder_others", {
|
||||
messageRecipient: directMessageRecipients
|
||||
.map((u) => u.name || `@${u.username}`)
|
||||
.join(", "),
|
||||
});
|
||||
} else {
|
||||
return I18n.t("chat.placeholder_others", {
|
||||
messageRecipient: `#${chatChannel.title}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"value",
|
||||
"loading",
|
||||
"disableComposer",
|
||||
"inProgressUploads.[]"
|
||||
)
|
||||
sendDisabled(value, loading, disableComposer, inProgressUploads) {
|
||||
if (loading || disableComposer || inProgressUploads.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this._messageIsValid();
|
||||
},
|
||||
|
||||
@action
|
||||
sendClicked() {
|
||||
if (this.site.mobileView) {
|
||||
// prevents android to hide the keyboard after sending a message
|
||||
// we do a focusTextarea later but it's too late for android
|
||||
document.querySelector(this.composerFocusSelector).focus();
|
||||
}
|
||||
|
||||
if (this.sendDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editingMessage
|
||||
? this.internalEditMessage()
|
||||
: this.internalSendMessage();
|
||||
},
|
||||
|
||||
@action
|
||||
internalSendMessage() {
|
||||
return this.sendMessage(this.value, this._uploads).then(this.reset);
|
||||
},
|
||||
|
||||
@action
|
||||
internalEditMessage() {
|
||||
return this.editMessage(
|
||||
this.editingMessage,
|
||||
this.value,
|
||||
this._uploads
|
||||
).then(this.reset);
|
||||
},
|
||||
|
||||
_messageIsValid() {
|
||||
const validLength =
|
||||
(this.value || "").trim().length >=
|
||||
(this.siteSettings.chat_minimum_message_length || 0);
|
||||
|
||||
if (this.canAttachUploads) {
|
||||
if (this._messageIsEmpty()) {
|
||||
// If message is empty, an an upload must present for sending to be enabled
|
||||
return this._uploads.length;
|
||||
} else {
|
||||
// Message is non-empty. Make sure it's long enough to be valid.
|
||||
return validLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments are disabled so for a message to be valid it must be long enough.
|
||||
return validLength;
|
||||
},
|
||||
|
||||
_messageIsEmpty() {
|
||||
return (this.value || "").trim() === "";
|
||||
},
|
||||
|
||||
@action
|
||||
reset() {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setProperties({
|
||||
value: "",
|
||||
inReplyMsg: null,
|
||||
});
|
||||
this._syncUploads([]);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelReplyTo() {
|
||||
this.set("replyToMsg", null);
|
||||
this.setInReplyToMsg(null);
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelEditing() {
|
||||
this.onCancelEditing();
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
},
|
||||
|
||||
_cursorIsOnEmptyLine() {
|
||||
const selectionStart = this._textarea.selectionStart;
|
||||
if (selectionStart === 0) {
|
||||
return true;
|
||||
} else if (this._textarea.value.charAt(selectionStart - 1) === "\n") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@action
|
||||
uploadsChanged(uploads) {
|
||||
this.set("_uploads", cloneJSON(uploads));
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
},
|
||||
|
||||
@action
|
||||
onTextareaFocusIn(target) {
|
||||
if (!this.capabilities.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hack to prevent the whole viewport
|
||||
// to move on focus input
|
||||
target = document.querySelector(".chat-composer-input");
|
||||
target.style.transform = "translateY(-99999px)";
|
||||
target.focus();
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
target.style.transform = "";
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
resizeTextarea() {
|
||||
schedule("afterRender", () => {
|
||||
if (!this._textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is a quirk which forces us to `auto` first or textarea
|
||||
// won't resize
|
||||
this._textarea.style.height = "auto";
|
||||
|
||||
// +1 is to workaround a rounding error visible on electron
|
||||
// causing scrollbars to show when they shouldn’t
|
||||
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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
Loading…
Reference in New Issue