DEV: start glimmer-ification and optimisations of chat plugin (#19531)

Note this is a very large PR, and some of it could have been splited, but keeping it one chunk made it to merge conflicts and to revert if necessary. Actual new code logic is also not that much, as most of the changes are removing js tests, adding system specs or moving things around.

To make it possible this commit is doing the following changes:

- converting (and adding new) existing js acceptances tests into system tests. This change was necessary to ensure as little regressions as possible while changing paradigm
- moving away from store. Using glimmer and tracked properties requires to have class objects everywhere and as a result works well with models. However store/adapters are suffering from many bugs and limitations. As a workaround the `chat-api` and `chat-channels-manager` are an answer to this problem by encapsulating backend calls and frontend storage logic; while still using js models.
- dropping `appEvents` as much as possible. Using tracked properties and a better local storage of channel models, allows to be much more reactive and doesn’t require arbitrary manual updates everywhere in the app.
- while working on replacing store, the existing work of a chat api (backend) has been continued to support more cases.
- removing code from the `chat` service to separate concerns, `chat-subscriptions-manager` and `chat-channels-manager`, being the largest examples of where the code has been rewritten/moved.

Future wok:
- improve behavior when closing/deleting a channel, it's already slightly buggy on live, it's rare enough that it's not a big issue, but should be improved
- improve page objects used in chat
- move more endpoints to the API
- finish temporarily skipped tests
- extract more code from the `chat` service
- use glimmer for `chat-messages`
- separate concerns in `chat-live-pane`
- eventually add js tests for `chat-api`, `chat-channels-manager` and `chat-subscriptions-manager`, they are indirectly heavy tested through system tests but it would be nice to at least test the public API

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
This commit is contained in:
Joffrey JAFFEUX 2022-12-21 13:21:02 +01:00 committed by GitHub
parent 269b6177c1
commit d2e24f9569
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
210 changed files with 6980 additions and 10753 deletions

View File

@ -58,7 +58,11 @@ export default {
this.onDoNotDisturb
);
this.messageBus.subscribe(`/user-status`, this.onUserStatus);
this.messageBus.subscribe(
`/user-status`,
this.onUserStatus,
this.currentUser.status?.message_bus_last_id
);
this.messageBus.subscribe("/categories", this.onCategories);

View File

@ -1,5 +1,9 @@
# frozen_string_literal: true
class UserStatusSerializer < ApplicationSerializer
attributes :description, :emoji, :ends_at
attributes :description, :emoji, :ends_at, :message_bus_last_id
def message_bus_last_id
MessageBus.last_id("/user-status")
end
end

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelMembershipsController < Chat::Api::ChatChannelsController
def index
channel = find_chat_channel
offset = (params[:offset] || 0).to_i
limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships =
ChatChannelMembershipsQuery.call(
channel,
offset: offset,
limit: limit,
username: params[:username],
)
render_serialized(memberships, UserChatChannelMembershipSerializer, root: false)
end
end

View File

@ -1,12 +0,0 @@
# frozen_string_literal: true
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChatChannelNotificationsSettingsController < Chat::Api::ChatChannelsController
def update
settings_params = params.permit(MEMBERSHIP_EDITABLE_PARAMS)
membership = find_membership
membership.update!(settings_params.to_h)
render_serialized(membership, UserChatChannelMembershipSerializer, root: false)
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
def create
existing_archive = channel_from_params.chat_channel_archive
if existing_archive.present?
guardian.ensure_can_change_channel_status!(channel_from_params, :archived)
raise Discourse::InvalidAccess if !existing_archive.failed?
Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
else
archive_params =
params
.require(:archive)
.tap do |ca|
ca.require(:type)
ca.permit(:title, :topic_id, :category_id, tags: [])
end
new_topic = archive_params[:type] == "new_topic"
raise Discourse::InvalidParameters if new_topic && archive_params[:title].blank?
raise Discourse::InvalidParameters if !new_topic && archive_params[:topic_id].blank?
if !guardian.can_change_channel_status?(channel_from_params, :read_only)
raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived"))
end
Chat::ChatChannelArchiveService.begin_archive_process(
chat_channel: channel_from_params,
acting_user: current_user,
topic_params: {
topic_id: archive_params[:topic_id],
topic_title: archive_params[:title],
category_id: archive_params[:category_id],
tags: archive_params[:tags],
},
)
end
render json: success_json
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
CHAT_CHANNEL_EDITABLE_PARAMS = %i[name description]
CATEGORY_CHAT_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
CHANNEL_EDITABLE_PARAMS = %i[name description]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
class Chat::Api::ChatChannelsController < Chat::Api
def index
@ -9,6 +9,9 @@ class Chat::Api::ChatChannelsController < Chat::Api
params.permit(:filter, :limit, :offset),
).symbolize_keys!
options[:offset] = options[:offset].to_i
options[:limit] = (options[:limit] || 25).to_i
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
@ -20,73 +23,166 @@ class Chat::Api::ChatChannelsController < Chat::Api
membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
)
end
render json: serialized_channels, root: false
pagination_options =
options.slice(:offset, :limit, :filter).merge(offset: options[:offset] + options[:limit])
pagination_params = pagination_options.map { |k, v| "#{k}=#{v}" }.join("&")
render json: serialized_channels,
root: "channels",
meta: {
load_more_url: "/chat/api/channels?#{pagination_params}",
}
end
def destroy
confirmation = params.require(:channel).require(:name_confirmation)&.downcase
guardian.ensure_can_delete_chat_channel!
if channel_from_params.title(current_user).downcase != confirmation
raise Discourse::InvalidParameters.new(:name_confirmation)
end
begin
ChatChannel.transaction do
channel_from_params.trash!(current_user)
StaffActionLogger.new(current_user).log_custom(
"chat_channel_delete",
{
chat_channel_id: channel_from_params.id,
chat_channel_name: channel_from_params.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: channel_from_params.id })
render json: success_json
end
def create
channel_params =
params.require(:channel).permit(:chatable_id, :name, :description, :auto_join_users)
guardian.ensure_can_create_chat_channel!
if channel_params[:name].length > SiteSetting.max_topic_title_length
raise Discourse::InvalidParameters.new(:name)
end
if ChatChannel.exists?(
chatable_type: "Category",
chatable_id: channel_params[:chatable_id],
name: channel_params[:name],
)
raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category"))
end
chatable = Category.find_by(id: channel_params[:chatable_id])
raise Discourse::NotFound unless chatable
auto_join_users =
ActiveRecord::Type::Boolean.new.deserialize(channel_params[:auto_join_users]) || false
channel =
chatable.create_chat_channel!(
name: channel_params[:name],
description: channel_params[:description],
user_count: 1,
auto_join_users: auto_join_users,
)
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
render_serialized(
channel,
ChatChannelSerializer,
membership: channel.membership_for(current_user),
root: "channel",
)
end
def show
render_serialized(
channel_from_params,
ChatChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
)
end
def update
guardian.ensure_can_edit_chat_channel!
chat_channel = find_chat_channel
if chat_channel.direct_message_channel?
if channel_from_params.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 = editable_params(params, channel_from_params)
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!
auto_join_limiter(channel_from_params).performed!
end
chat_channel.update!(params_to_edit)
channel_from_params.update!(params_to_edit)
ChatPublisher.publish_chat_channel_edit(chat_channel, current_user)
ChatPublisher.publish_chat_channel_edit(channel_from_params, current_user)
if chat_channel.category_channel? && chat_channel.auto_join_users
Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships
if channel_from_params.category_channel? && channel_from_params.auto_join_users
Chat::ChatChannelMembershipManager.new(
channel_from_params,
).enforce_automatic_channel_memberships
end
render_serialized(
chat_channel,
channel_from_params,
ChatChannelSerializer,
root: false,
membership: chat_channel.membership_for(current_user),
root: "channel",
membership: channel_from_params.membership_for(current_user),
)
end
private
def find_chat_channel
chat_channel = ChatChannel.find(params.require(:chat_channel_id))
guardian.ensure_can_join_chat_channel!(chat_channel)
chat_channel
def channel_from_params
@channel ||=
begin
channel = ChatChannel.find(params.require(:channel_id))
guardian.ensure_can_preview_chat_channel!(channel)
channel
end
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
def membership_from_params
@membership ||=
begin
membership =
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
raise Discourse::NotFound if membership.blank?
membership
end
end
def auto_join_limiter(chat_channel)
def auto_join_limiter(channel)
RateLimiter.new(
current_user,
"auto_join_users_channel_#{chat_channel.id}",
"auto_join_users_channel_#{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)
def editable_params(params, channel)
permitted_params = CHANNEL_EDITABLE_PARAMS
permitted_params += CATEGORY_CHANNEL_EDITABLE_PARAMS if channel.category_channel?
params.require(:channel).permit(*permitted_params)
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
def create
guardian.ensure_can_join_chat_channel!(channel_from_params)
render_serialized(
channel_from_params.add(current_user),
UserChatChannelMembershipSerializer,
root: "membership",
)
end
def destroy
render_serialized(
channel_from_params.remove(current_user),
UserChatChannelMembershipSerializer,
root: "membership",
)
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
def update
settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS)
membership_from_params.update!(settings_params.to_h)
render_serialized(
membership_from_params,
UserChatChannelMembershipSerializer,
root: "membership",
)
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController
def index
params.permit(:username, :offset, :limit)
offset = params[:offset].to_i
limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships =
ChatChannelMembershipsQuery.call(
channel_from_params,
offset: offset,
limit: limit,
username: params[:username],
)
render_serialized(
memberships,
UserChatChannelMembershipSerializer,
root: "memberships",
meta: {
total_rows: channel_from_params.user_count,
load_more_url:
"/chat/api/channels/#{channel_from_params.id}/memberships?offset=#{offset + limit}&limit=#{limit}&username=#{params[:username]}",
},
)
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
def create
move_params = params.require(:move)
move_params.require(:message_ids)
move_params.require(:destination_channel_id)
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
destination_channel =
Chat::ChatChannelFetcher.find_with_access_check(
move_params[:destination_channel_id],
guardian,
)
begin
message_ids = move_params[:message_ids].map(&:to_i)
moved_messages =
Chat::MessageMover.new(
acting_user: current_user,
source_channel: channel_from_params,
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
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
def update
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?(status) || status == "read_only" || status == "archive"
raise Discourse::InvalidParameters
end
guardian.ensure_can_change_channel_status!(channel_from_params, status.to_sym)
channel_from_params.public_send("#{status}!", current_user)
render_serialized(channel_from_params, ChatChannelSerializer, root: "channel")
end
end

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
class Chat::Api::ChatChatablesController < Chat::Api
def index
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
# FIXME: investigate the cost of this query
ChatChannel
.includes(chatable: :users)
.joins(direct_message: :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
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::Api::ChatCurrentUserChannelsController < Chat::Api
def index
structured = Chat::ChatChannelFetcher.structured(guardian)
render_serialized(structured, ChatChannelIndexSerializer, root: false)
end
end

View File

@ -1,250 +0,0 @@
# 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: :direct_message_users)
.group(1)
.having(
"ARRAY[?] <@ ARRAY_AGG(user_id) AND ARRAY[?] && ARRAY_AGG(user_id)",
[current_user.id],
users.map(&:id),
)
else
[]
end
)
user_ids_with_channel = []
direct_message_channels.each do |dm_channel|
user_ids = dm_channel.chatable.users.map(&:id)
user_ids_with_channel.concat(user_ids) if user_ids.count < 3
end
users_without_channel = users.filter { |u| !user_ids_with_channel.include?(u.id) }
if current_user.username.downcase.start_with?(filter)
# We filtered out the current user for the query earlier, but check to see
# if they should be included, and add.
users_without_channel << current_user
end
render_serialized(
{
public_channels: public_channels,
direct_message_channels: direct_message_channels,
users: users_without_channel,
memberships: memberships,
},
ChatChannelSearchSerializer,
root: false,
)
end
def archive
params.require(:type)
if params[:type] == "newTopic" ? params[:title].blank? : params[:topic_id].blank?
raise Discourse::InvalidParameters
end
if !guardian.can_change_channel_status?(@chat_channel, :read_only)
raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived"))
end
Chat::ChatChannelArchiveService.begin_archive_process(
chat_channel: @chat_channel,
acting_user: current_user,
topic_params: {
topic_id: params[:topic_id],
topic_title: params[:title],
category_id: params[:category_id],
tags: params[:tags],
},
)
render json: success_json
end
def retry_archive
guardian.ensure_can_change_channel_status!(@chat_channel, :archived)
archive = @chat_channel.chat_channel_archive
raise Discourse::NotFound if archive.blank?
raise Discourse::InvalidAccess if !archive.failed?
Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: @chat_channel)
render json: success_json
end
def change_status
params.require(:status)
# we only want to use this endpoint for open/closed status changes,
# the others are more "special" and are handled by the archive endpoint
if !ChatChannel.statuses.keys.include?(params[:status]) || params[:status] == "read_only" ||
params[:status] == "archive"
raise Discourse::InvalidParameters
end
guardian.ensure_can_change_channel_status!(@chat_channel, params[:status].to_sym)
@chat_channel.public_send("#{params[:status]}!", current_user)
render json: success_json
end
def destroy
params.require(:channel_name_confirmation)
guardian.ensure_can_delete_chat_channel!
if @chat_channel.title(current_user).downcase != params[:channel_name_confirmation].downcase
raise Discourse::InvalidParameters.new(:channel_name_confirmation)
end
begin
ChatChannel.transaction do
@chat_channel.trash!(current_user)
StaffActionLogger.new(current_user).log_custom(
"chat_channel_delete",
{
chat_channel_id: @chat_channel.id,
chat_channel_name: @chat_channel.title(current_user),
},
)
end
rescue ActiveRecord::Rollback
return render_json_error(I18n.t("chat.errors.delete_channel_failed"))
end
Jobs.enqueue(:chat_channel_delete, { chat_channel_id: @chat_channel.id })
render json: success_json
end
end

View File

@ -110,7 +110,9 @@ class Chat::ChatController < Chat::ChatBaseController
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update(last_read_message_id: chat_message_creator.chat_message.id)
@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
@ -123,14 +125,15 @@ class Chat::ChatController < Chat::ChatBaseController
).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),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
@ -398,34 +401,6 @@ class Chat::ChatController < Chat::ChatBaseController
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!

View File

@ -13,7 +13,7 @@ class Chat::DirectMessagesController < Chat::ChatBaseController
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "chat_channel",
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
@ -31,7 +31,7 @@ class Chat::DirectMessagesController < Chat::ChatBaseController
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "chat_channel",
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else

View File

@ -15,7 +15,19 @@ module Jobs
return
end
return if channel_archive.complete?
if channel_archive.complete?
channel_archive.chat_channel.update!(status: :archived)
ChatPublisher.publish_archive_status(
channel_archive.chat_channel,
archive_status: :success,
archived_messages: channel_archive.archived_messages,
archive_topic_id: channel_archive.destination_topic_id,
total_messages: channel_archive.total_messages,
)
return
end
DistributedMutex.synchronize(
"archive_chat_channel_#{channel_archive.chat_channel_id}",

View File

@ -20,7 +20,7 @@ class ChatChannelSerializer < ApplicationSerializer
:archive_topic_id,
:memberships_count,
:current_user_membership,
:message_bus_last_ids
:meta
def initialize(object, opts)
super(object, opts)
@ -61,7 +61,7 @@ class ChatChannelSerializer < ApplicationSerializer
end
def include_archive_status?
scope.is_staff? && object.archived? && archive.present?
scope.is_staff? && (object.archived? || archive&.failed?) && archive.present?
end
def archive_completed
@ -88,8 +88,11 @@ class ChatChannelSerializer < ApplicationSerializer
scope.can_edit_chat_channel?
end
def include_current_user_membership?
@current_user_membership.present?
end
def current_user_membership
return if !@current_user_membership
@current_user_membership.chat_channel = object
UserChatChannelMembershipSerializer.new(
@current_user_membership,
@ -98,10 +101,17 @@ class ChatChannelSerializer < ApplicationSerializer
).as_json
end
def message_bus_last_ids
def meta
{
new_messages: @opts[:new_messages_message_bus_last_id] || MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)),
new_mentions: @opts[:new_mentions_message_bus_last_id] || MessageBus.last_id(ChatPublisher.new_mentions_message_bus_channel(object.id)),
message_bus_last_ids: {
new_messages:
@opts[:new_messages_message_bus_last_id] ||
MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)),
new_mentions:
@opts[:new_mentions_message_bus_last_id] ||
MessageBus.last_id(ChatPublisher.new_mentions_message_bus_channel(object.id)),
archive_status: MessageBus.last_id("/chat/channel-archive-status"),
},
}
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class StructuredChannelSerializer < ApplicationSerializer
attributes :public_channels, :direct_message_channels, :message_bus_last_ids
attributes :public_channels, :direct_message_channels, :meta
def public_channels
object[:public_channels].map do |channel|
@ -10,8 +10,10 @@ class StructuredChannelSerializer < ApplicationSerializer
root: nil,
scope: scope,
membership: channel_membership(channel.id),
new_messages_message_bus_last_id: chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)],
new_mentions_message_bus_last_id: chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)]
new_messages_message_bus_last_id:
chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)],
new_mentions_message_bus_last_id:
chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)],
)
end
end
@ -23,8 +25,10 @@ class StructuredChannelSerializer < ApplicationSerializer
root: nil,
scope: scope,
membership: channel_membership(channel.id),
new_messages_message_bus_last_id: chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)],
new_mentions_message_bus_last_id: chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)]
new_messages_message_bus_last_id:
chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)],
new_mentions_message_bus_last_id:
chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)],
)
end
end
@ -34,47 +38,54 @@ class StructuredChannelSerializer < ApplicationSerializer
object[:memberships].find { |membership| membership.chat_channel_id == channel_id }
end
def message_bus_last_ids
ids = {
channel_metadata: chat_message_bus_last_ids[ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL],
def meta
last_ids = {
channel_metadata:
chat_message_bus_last_ids[ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL],
channel_edits: chat_message_bus_last_ids[ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL],
channel_status: chat_message_bus_last_ids[ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL],
new_channel: chat_message_bus_last_ids[ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL]
new_channel: chat_message_bus_last_ids[ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL],
}
if id = chat_message_bus_last_ids[ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id)]
ids[:user_tracking_state] = id
if id =
chat_message_bus_last_ids[
ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id)
]
last_ids[:user_tracking_state] = id
end
ids
{ message_bus_last_ids: last_ids }
end
private
def chat_message_bus_last_ids
@chat_message_bus_last_ids ||= begin
message_bus_channels = [
ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL,
ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL,
ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL,
ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL,
]
@chat_message_bus_last_ids ||=
begin
message_bus_channels = [
ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL,
ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL,
ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL,
ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL,
]
if !scope.anonymous?
message_bus_channels.push(ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id))
if !scope.anonymous?
message_bus_channels.push(
ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id),
)
end
object[:public_channels].each do |channel|
message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id))
message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id))
end
object[:direct_message_channels].each do |channel|
message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id))
message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id))
end
MessageBus.last_ids(*message_bus_channels)
end
object[:public_channels].each do |channel|
message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id))
message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id))
end
object[:direct_message_channels].each do |channel|
message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id))
message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id))
end
MessageBus.last_ids(*message_bus_channels)
end
end
end

View File

@ -20,6 +20,7 @@ module ChatPublisher
MessageBus.publish(
self.new_messages_message_bus_channel(chat_channel.id),
{
channel_id: chat_channel.id,
message_id: chat_message.id,
user_id: chat_message.user.id,
username: chat_message.user.username,
@ -145,7 +146,7 @@ module ChatPublisher
def self.publish_new_mention(user_id, chat_channel_id, chat_message_id)
MessageBus.publish(
self.new_mentions_message_bus_channel(chat_channel_id),
{ message_id: chat_message_id }.as_json,
{ message_id: chat_message_id, channel_id: chat_channel_id }.as_json,
user_ids: [user_id],
)
end
@ -154,13 +155,20 @@ module ChatPublisher
def self.publish_new_channel(chat_channel, users)
users.each do |user|
# FIXME: This could generate a lot of queries depending on the amount of users
membership = chat_channel.membership_for(user)
# TODO: this event is problematic as some code will update the membership before calling it
# and other code will update it after calling it
# it means frontend must handle logic for both cases
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),
root: :channel,
membership: membership,
).as_json
MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id])
end
end
@ -179,9 +187,10 @@ module ChatPublisher
type: :mention_warning,
chat_message_id: chat_message.id,
cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json,
without_membership: without_membership.map { |u| { username: u.username, id: u.id } }.as_json,
without_membership:
without_membership.map { |u| { username: u.username, id: u.id } }.as_json,
groups_with_too_many_members: too_many_members.map(&:name).as_json,
group_mentions_disabled: mentions_disabled.map(&:name).as_json
group_mentions_disabled: mentions_disabled.map(&:name).as_json,
},
user_ids: [user_id],
)

View File

@ -1,6 +1,8 @@
import RESTAdapter from "discourse/adapters/rest";
export default class ChatMessage extends RESTAdapter {
jsonMode = true;
pathFor(store, type, findArgs) {
if (findArgs.targetMessageId) {
return `/chat/lookup/${findArgs.targetMessageId}.json?chat_channel_id=${findArgs.channelId}`;

View File

@ -1,5 +1,11 @@
{{#if (and this.showMobileDirectMessageButton this.canCreateDirectMessageChannel)}}
<LinkTo @route="chat.draft-channel" class="btn-flat open-draft-channel-page-btn keep-mobile-sidebar-open btn-floating" title={{i18n this.createDirectMessageChannelLabel}}>
{{#if
(and this.showMobileDirectMessageButton this.canCreateDirectMessageChannel)
}}
<LinkTo
@route="chat.draft-channel"
class="btn-flat open-draft-channel-page-btn keep-mobile-sidebar-open btn-floating"
title={{i18n this.createDirectMessageChannelLabel}}
>
{{d-icon "plus"}}
</LinkTo>
{{/if}}
@ -8,7 +14,10 @@
role="region"
aria-label={{i18n "chat.aria_roles.channels_list"}}
class="channels-list"
{{on "scroll" (if this.chatStateManager.isFullPageActive this.storeScrollPosition (noop))}}
{{on
"scroll"
(if this.chatStateManager.isFullPageActive this.storeScrollPosition (noop))
}}
>
{{#if this.displayPublicChannels}}
<div class="chat-channel-divider public-channels-section">
@ -26,7 +35,11 @@
{{/if}}
<span class="channel-title">{{i18n "chat.chat_channels"}}</span>
<LinkTo @route="chat.browse" class="btn no-text btn-flat open-browse-page-btn title-action" title={{i18n "chat.channels_list_popup.browse"}}>
<LinkTo
@route="chat.browse"
class="btn no-text btn-flat open-browse-page-btn title-action"
title={{i18n "chat.channels_list_popup.browse"}}
>
{{d-icon "pencil-alt"}}
</LinkTo>
</div>
@ -40,7 +53,7 @@
</LinkTo>
</div>
{{else}}
{{#each this.publicChannels as |channel|}}
{{#each this.chatChannelsManager.publicMessageChannels as |channel|}}
<ChatChannelRow
@channel={{channel}}
@options={{hash settingsButton=true}}
@ -75,8 +88,17 @@
{{/if}}
<span class="channel-title">{{i18n "chat.direct_messages.title"}}</span>
{{#if (and this.canCreateDirectMessageChannel (not this.showMobileDirectMessageButton))}}
<LinkTo @route="chat.draft-channel" class="btn no-text btn-flat open-draft-channel-page-btn" title={{i18n this.createDirectMessageChannelLabel}}>
{{#if
(and
this.canCreateDirectMessageChannel
(not this.showMobileDirectMessageButton)
)
}}
<LinkTo
@route="chat.draft-channel"
class="btn no-text btn-flat open-draft-channel-page-btn"
title={{i18n this.createDirectMessageChannelLabel}}
>
{{d-icon "plus"}}
</LinkTo>
{{/if}}
@ -84,11 +106,8 @@
{{/if}}
<div id="direct-message-channels" class={{this.directMessageChannelClasses}}>
{{#each this.sortedDirectMessageChannels as |channel|}}
<ChatChannelRow
@channel={{channel}}
@options={{hash leaveButton=true}}
/>
{{#each this.chatChannelsManager.truncatedDirectMessageChannels as |channel|}}
<ChatChannelRow @channel={{channel}} @options={{hash leaveButton=true}} />
{{/each}}
</div>
</div>

View File

@ -3,18 +3,18 @@ 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 { and, empty } from "@ember/object/computed";
export default class ChannelsList extends Component {
@service chat;
@service router;
@service chatStateManager;
@service chatChannelsManager;
tagName = "";
inSidebar = false;
toggleSection = null;
@reads("chat.publicChannels.[]") publicChannels;
@reads("chat.directMessageChannels.[]") directMessageChannels;
@empty("publicChannels") publicChannelsEmpty;
@empty("chatChannelsManager.publicMessageChannels")
publicMessageChannelsEmpty;
@and("site.mobileView", "showDirectMessageChannels")
showMobileDirectMessageButton;
@ -27,11 +27,14 @@ export default class ChannelsList extends Component {
return "chat.direct_messages.new";
}
@computed("canCreateDirectMessageChannel", "directMessageChannels")
@computed(
"canCreateDirectMessageChannel",
"chatChannelsManager.directMessageChannels"
)
get showDirectMessageChannels() {
return (
this.canCreateDirectMessageChannel ||
this.directMessageChannels?.length > 0
this.chatChannelsManager.directMessageChannels?.length > 0
);
}
@ -39,17 +42,6 @@ export default class ChannelsList extends Component {
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 ${
@ -58,11 +50,11 @@ export default class ChannelsList extends Component {
}
@computed(
"publicChannelsEmpty",
"publicMessageChannelsEmpty",
"currentUser.{staff,has_joinable_public_channels}"
)
get displayPublicChannels() {
if (this.publicChannelsEmpty) {
if (this.publicMessageChannelsEmpty) {
return (
this.currentUser?.staff ||
this.currentUser?.has_joinable_public_channels

View File

@ -1,12 +1,16 @@
{{#if this.chatProgressBarContainer}}
{{#in-element this.chatProgressBarContainer}}
<DProgressBar @key="browse-list" @isLoading={{this.isLoading}} />
<DProgressBar @key="browse-list" @isLoading={{this.channelsCollection.loading}} />
{{/in-element}}
{{/if}}
<div class="chat-browse-view__header chat-full-page-header">
{{#if this.site.mobileView}}
<LinkTo @route="chat.index" class="chat-full-page-header__back-btn no-text btn-flat btn" title={{i18n "chat.browse.back"}}>
<LinkTo
@route="chat.index"
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.browse.back"}}
>
{{d-icon "chevron-left"}}
</LinkTo>
{{/if}}
@ -17,7 +21,10 @@
<DButton
@action={{action "createChannel"}}
@icon="plus"
@class={{concat-class "new-channel-btn" (if this.site.mobileView "btn-flat")}}
@class={{concat-class
"new-channel-btn"
(if this.site.mobileView "btn-flat")
}}
@label={{if this.site.desktopView "chat.create_channel.title"}}
/>
{{/if}}
@ -49,7 +56,7 @@
/>
</div>
{{#if (and (not this.channels.length) (not this.isLoading))}}
{{#if (and (not this.channelsCollection.length) (not this.channelsCollection.loading))}}
<div class="empty-state">
<span class="empty-state-title">{{i18n "chat.empty_state.title"}}</span>
<div class="empty-state-body">
@ -60,16 +67,16 @@
</LinkTo>
</div>
</div>
{{else if this.channels.length}}
{{else if this.channelsCollection.length}}
<div class="chat-browse-view__content_wrapper">
<div class="chat-browse-view__content">
<div class="chat-browse-view__cards">
{{#each this.channels as |channel|}}
{{#each this.channelsCollection as |channel|}}
<ChatChannelCard @channel={{channel}} />
{{/each}}
</div>
{{#unless this.isLoading}}
{{#unless this.channelsCollection.loading}}
<OnVisibilityAction @action={{action "onScroll"}} />
{{/unless}}
</div>

View File

@ -1,62 +1,28 @@
import { INPUT_DELAY } from "discourse-common/config/environment";
import Component from "@ember/component";
import { action, computed } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { schedule } from "@ember/runloop";
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 = [];
@service chatApi;
tagName = "";
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;
if (!this.channelsCollection) {
this.set("channelsCollection", this.chatApi.channels());
}
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;
}
this.channelsCollection.load({
filter: this.filter,
status: this.status,
});
}
@computed("siteSettings.chat_allow_archiving_channels")
@ -74,19 +40,20 @@ export default class ChatBrowseView extends Component {
@action
onScroll() {
if (this.isLoading) {
return;
}
discourseDebounce(this, this.fetchChannels, INPUT_DELAY);
discourseDebounce(
this,
this.channelsCollection.loadMore,
{ filter: this.filter, status: this.status },
INPUT_DELAY
);
}
@action
debouncedFiltering(event) {
discourseDebounce(
this,
this.filterChannels,
event.target.value,
this.channelsCollection.load,
{ filter: event.target.value, status: this.status },
INPUT_DELAY
);
}
@ -100,14 +67,4 @@ export default class ChatBrowseView extends Component {
focusFilterInput(input) {
schedule("afterRender", () => input?.focus());
}
@bind
filterChannels(filter) {
this.canLoadMore = true;
this.filter = filter;
this.channels = [];
this.offset = 0;
this.fetchChannels();
}
}

View File

@ -59,7 +59,6 @@
<div class="chat-form__section">
<ToggleChannelMembershipButton
@channel={{this.channel}}
@onToggle={{action "afterMembershipToggle"}}
@options={{hash joinClass="btn-primary" leaveClass="btn-flat" joinIcon="sign-in-alt" leaveIcon="sign-out-alt"}}
/>
</div>

View File

@ -1,5 +1,4 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatChannelAboutView extends Component {
@ -9,11 +8,4 @@ export default class ChatChannelAboutView extends Component {
onEditChatChannelTitle = null;
onEditChatChannelDescription = null;
isLoading = false;
@action
afterMembershipToggle() {
this.chat.forceRefreshChannels().then(() => {
this.chat.openChannel(this.channel);
});
}
}

View File

@ -5,7 +5,6 @@ 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 {
@ -14,13 +13,15 @@ import {
} 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";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Component.extend({
export default Component.extend(ModalFunctionality, {
chat: service(),
chatApi: service(),
tagName: "",
chatChannel: null,
selection: "newTopic",
selection: NEW_TOPIC_SELECTION,
newTopic: equal("selection", NEW_TOPIC_SELECTION),
existingTopic: equal("selection", EXISTING_TOPIC_SELECTION),
@ -34,18 +35,12 @@ export default Component.extend({
@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);
return this.chatApi
.createChannelArchive(this.chatChannel.id, this._data())
.then((result) => {
this.flash(I18n.t("chat.channel_archive.process_started"), "success");
result.target.status = CHANNEL_STATUSES.archived;
discourseLater(() => {
this.closeModal();
@ -58,7 +53,6 @@ export default Component.extend({
_data() {
const data = {
type: this.selection,
chat_channel_id: this.chatChannel.id,
};
if (this.newTopic) {
data.title = this.topicTitle;

View File

@ -2,14 +2,15 @@ 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, { bind } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service";
export default Component.extend({
channel: null,
tagName: "",
chatApi: service(),
@discourseComputed(
"channel.status",
@ -43,26 +44,32 @@ export default Component.extend({
@action
retryArchive() {
return ajax({
url: `/chat/chat_channels/${this.channel.id}/retry_archive.json`,
type: "PUT",
})
.then(() => {
this.channel.set("archive_failed", false);
})
return this.chatApi
.createChannelArchive(this.channel.id)
.catch(popupAjaxError);
},
didInsertElement() {
this._super(...arguments);
if (this.currentUser.admin) {
this.messageBus.subscribe("/chat/channel-archive-status", this.onMessage);
this.messageBus.subscribe(
"/chat/channel-archive-status",
this.onMessage,
this.channel.meta.message_bus_last_ids.archive_status
);
}
},
willDestroyElement() {
this._super(...arguments);
this.messageBus.unsubscribe("/chat/channel-archive-status", this.onMessage);
if (this.currentUser.admin) {
this.messageBus.unsubscribe(
"/chat/channel-archive-status",
this.onMessage
);
}
},
_getTopicUrl() {

View File

@ -1,33 +1,34 @@
{{#if this.channel}}
{{#if @channel}}
<div
class={{concat-class
"chat-channel-card"
(if this.channel.isClosed "-closed")
(if this.channel.isArchived "-archived")
(if @channel.isClosed "-closed")
(if @channel.isArchived "-archived")
}}
style={{border-color this.channel.chatable.color}}
style={{border-color @channel.chatable.color}}
data-channel-id={{@channel.id}}
>
<div class="chat-channel-card__header">
<LinkTo
@route="chat.channel"
@models={{array this.channel.id (slugify-channel this.channel)}}
@models={{array @channel.id (slugify-channel @channel)}}
class="chat-channel-card__name-container"
>
<span class="chat-channel-card__name">
{{replace-emoji this.channel.escapedTitle}}
{{replace-emoji @channel.escapedTitle}}
</span>
{{#if this.channel.chatable.read_restricted}}
{{#if @channel.chatable.read_restricted}}
{{d-icon "lock" class="chat-channel-card__read-restricted"}}
{{/if}}
</LinkTo>
<div class="chat-channel-card__header-actions">
{{#if this.channel.current_user_membership.muted}}
{{#if @channel.currentUserMembership.muted}}
<LinkTo
@route="chat.channel.info.settings"
@models={{array
this.channel.id
(slugify-channel this.channel)
@channel.id
(slugify-channel @channel)
}}
class="chat-channel-card__tag -muted"
tabindex="-1"
@ -38,7 +39,7 @@
<LinkTo
@route="chat.channel.info.settings"
@models={{array this.channel.id (slugify-channel this.channel)}}
@models={{array @channel.id (slugify-channel @channel)}}
class="chat-channel-card__setting"
tabindex="-1"
>
@ -47,32 +48,30 @@
</div>
</div>
{{#if this.channel.description}}
{{#if @channel.description}}
<div class="chat-channel-card__description">
{{replace-emoji this.channel.escapedDescription}}
{{replace-emoji @channel.escapedDescription}}
</div>
{{/if}}
<div class="chat-channel-card__cta">
{{#if this.channel.isFollowing}}
{{#if @channel.isFollowing}}
<div class="chat-channel-card__tags">
<span class="chat-channel-card__tag -joined">
{{i18n "chat.joined"}}
</span>
<ToggleChannelMembershipButton
@channel={{this.channel}}
@onToggle={{action "afterMembershipToggle"}}
@channel={{@channel}}
@options={{hash
leaveClass="btn-link btn-small chat-channel-card__leave-btn"
labelType="short"
}}
/>
</div>
{{else if this.channel.isJoinable}}
{{else if @channel.isJoinable}}
<ToggleChannelMembershipButton
@channel={{this.channel}}
@onToggle={{action "afterMembershipToggle"}}
@channel={{@channel}}
@options={{hash
joinClass="btn-primary btn-small chat-channel-card__join-btn"
labelType="short"
@ -80,16 +79,16 @@
/>
{{/if}}
{{#if (gt this.channel.membershipsCount 0)}}
{{#if (gt @channel.membershipsCount 0)}}
<LinkTo
@route="chat.channel.info.members"
@models={{array this.channel.id (slugify-channel this.channel)}}
@models={{array @channel.id (slugify-channel @channel)}}
class="chat-channel-card__members"
tabindex="-1"
>
{{i18n
"chat.channel.memberships_count"
count=this.channel.membershipsCount
count=@channel.membershipsCount
}}
</LinkTo>
{{/if}}

View File

@ -1,13 +1,6 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ChatChannelCard extends Component {
@service chat;
tagName = "";
@action
afterMembershipToggle() {
this.chat.forceRefreshChannels();
}
}

View File

@ -3,14 +3,15 @@ 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";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Component.extend({
export default Component.extend(ModalFunctionality, {
chat: service(),
chatApi: service(),
router: service(),
tagName: "",
chatChannel: null,
@ -37,16 +38,14 @@ export default Component.extend({
@action
deleteChannel() {
this.set("deleting", true);
return ajax(`/chat/chat_channels/${this.chatChannel.id}.json`, {
method: "DELETE",
data: { channel_name_confirmation: this.channelNameConfirmation },
})
return this.chatApi
.destroyChannel(this.chatChannel.id, {
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",
});
this.flash(I18n.t("chat.channel_delete.process_started"), "success");
discourseLater(() => {
this.closeModal();

View File

@ -1,6 +1,6 @@
{{#if this.chatProgressBarContainer}}
{{#in-element this.chatProgressBarContainer}}
<DProgressBar @key="members-view" @isLoading={{this.isFetchingMembers}} />
<DProgressBar @key="members-view" @isLoading={{this.members.loading}} />
{{/in-element}}
{{/if}}
@ -22,15 +22,15 @@
>
<div role="list" class="channel-members-view__list">
{{#each this.members as |member|}}
{{#each this.members as |membership|}}
<a
class="channel-members-view__list-item"
href={{member.user.userPath}}
data-user-card={{member.user.username}}
href={{membership.user.userPath}}
data-user-card={{membership.user.username}}
tabindex="0"
>
<ChatUserAvatar @user={{member.user}} @avatarSize="medium" />
<ChatUserDisplayName @user={{member.user}} />
<ChatUserAvatar @user={{membership.user}} @avatarSize="medium" />
<ChatUserDisplayName @user={{membership.user}} />
</a>
{{else}}
{{#unless this.isFetchingMembers}}

View File

@ -1,24 +1,20 @@
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;
import { inject as service } from "@ember/service";
export default class ChatChannelMembersView extends Component {
@service chatApi;
tagName = "";
channel = null;
members = null;
isSearchFocused = false;
isFetchingMembers = false;
onlineUsers = null;
offset = 0;
filter = null;
inputSelector = "channel-members-view__search-input";
canLoadMore = true;
members = null;
didInsertElement() {
this._super(...arguments);
@ -28,14 +24,15 @@ export default class ChatChannelMembersView extends Component {
}
this._focusSearch();
this.set("members", []);
this.fetchMembers();
this.set("members", this.chatApi.listChannelMemberships(this.channel.id));
this.members.load();
this.appEvents.on("chat:refresh-channel-members", this, "onFilterMembers");
}
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat:refresh-channel-members", this, "onFilterMembers");
}
@ -46,59 +43,18 @@ export default class ChatChannelMembersView extends Component {
@action
onFilterMembers(username) {
this.set("filter", username);
this.set("offset", 0);
this.set("canLoadMore", true);
discourseDebounce(
this,
this.fetchMembers,
this.filter,
this.offset,
this.members.load,
{ username: this.filter },
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);
});
discourseDebounce(this, this.members.loadMore, INPUT_DELAY);
}
_focusSearch() {

View File

@ -13,7 +13,6 @@
{{#if this.showJoinButton}}
<ToggleChannelMembershipButton
@channel={{this.channel}}
@onToggle={{this.afterMembershipToggle}}
@options={{hash joinClass="btn-primary"}}
/>
{{/if}}

View File

@ -1,6 +1,6 @@
import Component from "@ember/component";
import { isEmpty } from "@ember/utils";
import { action, computed } from "@ember/object";
import { computed } from "@ember/object";
import { readOnly } from "@ember/object/computed";
import { inject as service } from "@ember/service";
@ -16,11 +16,4 @@ export default class ChatChannelPreviewCard extends Component {
get hasDescription() {
return !isEmpty(this.channel.description);
}
@action
afterMembershipToggle() {
this.chat.forceRefreshChannels().then(() => {
this.chat.openChannel(this.channel);
});
}
}

View File

@ -4,7 +4,7 @@
class={{concat-class
"chat-channel-row"
(if @channel.focused "focused")
(if @channel.current_user_membership.muted "muted")
(if @channel.currentUserMembership.muted "muted")
(if @options.leaveButton "can-leave")
(if (eq this.chat.activeChannel.id @channel.id) "active")
(if this.channelHasUnread "has-unread")

View File

@ -19,11 +19,7 @@ export default class ChatChannelRow extends Component {
}
get channelHasUnread() {
return (
this.currentUser.get(
`chat_channel_tracking_state.${this.args.channel?.id}.unread_count`
) > 0
);
return this.args.channel.currentUserMembership.unread_count > 0;
}
get #firstDirectMessageUser() {

View File

@ -17,24 +17,24 @@ export default Component.extend({
channels: null,
searchIndex: 0,
loading: false,
init() {
this._super(...arguments);
this.appEvents.on("chat-channel-selector-modal:close", this.close);
this.getInitialChannels();
},
chatChannelsManager: service(),
didInsertElement() {
this._super(...arguments);
this.appEvents.on("chat-channel-selector-modal:close", this.close);
document.addEventListener("keyup", this.onKeyUp);
document
.getElementById("chat-channel-selector-modal-inner")
?.addEventListener("mouseover", this.mouseover);
document.getElementById("chat-channel-selector-input")?.focus();
this.getInitialChannels();
},
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat-channel-selector-modal:close", this.close);
document.removeEventListener("keyup", this.onKeyUp);
document
@ -101,16 +101,17 @@ export default Component.extend({
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();
});
const newChannel = this.chatChannelsManager.store(response.channel);
return this.chatChannelsManager.follow(newChannel).then((c) => {
this.chat.openChannel(c);
this.close();
});
});
} else {
this.chat.openChannel(channel);
this.close();
return this.chatChannelsManager.follow(channel).then((c) => {
this.chat.openChannel(c);
this.close();
});
}
},
@ -135,7 +136,7 @@ export default Component.extend({
searchIndex: this.searchIndex + 1,
});
const thisSearchIndex = this.searchIndex;
ajax("/chat/chat_channels/search", { data: { filter } })
ajax("/chat/api/chatables", { data: { filter } })
.then((searchModel) => {
if (this.searchIndex === thisSearchIndex) {
this.set("searchModel", searchModel);
@ -149,7 +150,11 @@ export default Component.extend({
}
});
this.setProperties({
channels: channels.map((channel) => ChatChannel.create(channel)),
channels: channels.map((channel) => {
return channel.user
? ChatChannel.create(channel)
: this.chatChannelsManager.store(channel);
}),
loading: false,
});
this.focusFirstChannel(this.channels);
@ -160,10 +165,9 @@ export default Component.extend({
@action
getInitialChannels() {
return this.chat.getChannelsWithFilter(this.filter).then((channels) => {
this.focusFirstChannel(channels);
this.set("channels", channels);
});
const channels = this.getChannelsWithFilter(this.filter);
this.set("channels", channels);
this.focusFirstChannel(channels);
},
@action
@ -178,4 +182,44 @@ export default Component.extend({
channels.forEach((c) => c.set("focused", false));
channels[0]?.set("focused", true);
},
getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
let sortedChannels = this.chatChannelsManager.channels.sort((a, b) => {
return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at)
? -1
: 1;
});
const trimmedFilter = filter.trim();
const lowerCasedFilter = filter.toLowerCase();
const { activeChannel } = this;
return sortedChannels.filter((channel) => {
if (
opts.excludeActiveChannel &&
activeChannel &&
activeChannel.id === channel.id
) {
return false;
}
if (!trimmedFilter.length) {
return true;
}
if (channel.isDirectMessageChannel) {
let userFound = false;
channel.chatable.users.forEach((user) => {
if (
user.username.toLowerCase().includes(lowerCasedFilter) ||
user.name?.toLowerCase().includes(lowerCasedFilter)
) {
return (userFound = true);
}
});
return userFound;
} else {
return channel.title.toLowerCase().includes(lowerCasedFilter);
}
});
},
});

View File

@ -3,13 +3,13 @@
<label class="chat-form__label">
<span>{{i18n "chat.settings.mute"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{this.channel.current_user_membership.muted}}
@property={{this.channel.currentUserMembership.muted}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.mutedOptions}}
@value={{this.channel.current_user_membership.muted}}
@value={{this.channel.currentUserMembership.muted}}
@valueProperty="value"
@class="channel-settings-view__muted-selector"
@onChange={{action (fn this.saveNotificationSettings "muted")}}
@ -17,18 +17,18 @@
</div>
</div>
{{#unless this.channel.current_user_membership.muted}}
{{#unless this.channel.currentUserMembership.muted}}
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.settings.desktop_notification_level"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{this.channel.current_user_membership.desktop_notification_level}}
@property={{this.channel.currentUserMembership.desktop_notification_level}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.notificationLevels}}
@value={{this.channel.current_user_membership.desktop_notification_level}}
@value={{this.channel.currentUserMembership.desktop_notification_level}}
@valueProperty="value"
@class="channel-settings-view__desktop-notification-level-selector"
@onChange={{action
@ -42,13 +42,13 @@
<label class="chat-form__label">
<span>{{i18n "chat.settings.mobile_notification_level"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{this.channel.current_user_membership.mobile_notification_level}}
@property={{this.channel.currentUserMembership.mobile_notification_level}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.notificationLevels}}
@value={{this.channel.current_user_membership.mobile_notification_level}}
@value={{this.channel.currentUserMembership.mobile_notification_level}}
@valueProperty="value"
@class="channel-settings-view__mobile-notification-level-selector"
@onChange={{action

View File

@ -1,7 +1,6 @@
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 { Promise } from "rsvp";
@ -33,6 +32,7 @@ const CHANNEL_WIDE_MENTIONS_OPTIONS = [
export default class ChatChannelSettingsView extends Component {
@service chat;
@service chatApi;
@service chatGuardian;
@service router;
@service dialog;
@ -86,16 +86,26 @@ export default class ChatChannelSettingsView extends Component {
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,
return this.chatApi
.updateCurrentUserChatChannelNotificationsSettings(
this.channel.id,
settings
)
.then((result) => {
[
"muted",
"desktop_notification_level",
"mobile_notification_level",
].forEach((property) => {
if (
result.membership[property] !==
this.channel.currentUserMembership[property]
) {
this.channel.currentUserMembership[property] =
result.membership[property];
}
});
});
});
}
@action
@ -155,9 +165,10 @@ export default class ChatChannelSettingsView extends Component {
const payload = {};
payload[property] = value;
return ChatApi.modifyChatChannel(channel.id, payload)
.then((updatedChannel) => {
channel.set(property, updatedChannel[property]);
return this.chatApi
.updateChannel(channel.id, payload)
.then((result) => {
channel.set(property, result.channel[property]);
})
.catch((event) => {
if (event.jqXHR?.responseJSON?.errors) {

View File

@ -3,12 +3,12 @@ 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 chatApi;
@service router;
tagName = "";
channel = null;
@ -47,16 +47,11 @@ export default class ChatChannelToggleView extends Component {
? 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)
return this.chatApi
.updateChannelStatus(this.channel.id, status)
.finally(() => {
this.onStatusChange?.(this.channel);
});
})
.catch(popupAjaxError);
}
}

View File

@ -1,5 +1,16 @@
{{#if this.hasUnread}}
<div class="chat-channel-unread-indicator {{if this.isUrgent "urgent"}}">
<div class="number">{{this.unreadCount}}</div>
{{#if (gt @channel.currentUserMembership.unread_count 0)}}
<div
class={{concat-class
"chat-channel-unread-indicator"
(if
(or
@channel.isDirectMessageChannel
(gt @channel.currentUserMembership.unread_mentions 0)
)
"urgent"
)
}}
>
<div class="number">{{@channel.currentUserMembership.unread_count}}</div>
</div>
{{/if}}

View File

@ -1,46 +0,0 @@
import discourseComputed from "discourse-common/utils/decorators";
import Component from "@ember/component";
import { equal, gt } from "@ember/object/computed";
import { CHATABLE_TYPES } from "discourse/plugins/chat/discourse/models/chat-channel";
export default Component.extend({
tagName: "",
channel: null,
isDirectMessage: equal(
"channel.chatable_type",
CHATABLE_TYPES.directMessageChannel
),
hasUnread: gt("unreadCount", 0),
@discourseComputed(
"currentUser.chat_channel_tracking_state.@each.{unread_count,unread_mentions}",
"channel.id"
)
channelTrackingState(state, channelId) {
return state?.[channelId];
},
@discourseComputed(
"channelTrackingState.unread_mentions",
"channel",
"isDirectMessage"
)
isUrgent(unreadMentions, channel, isDirectMessage) {
if (!channel) {
return;
}
return isDirectMessage || unreadMentions > 0;
},
@discourseComputed("channelTrackingState.unread_count", "channel")
unreadCount(unreadCount, channel) {
if (!channel) {
return;
}
return unreadCount || 0;
},
});

View File

@ -34,7 +34,7 @@ export default class ChatDraftChannelScreen extends Component {
this.set(
"previewedChannel",
ChatChannel.create(
Object.assign({}, response.chat_channel, { isDraft: true })
Object.assign({}, response.channel, { isDraft: true })
)
);
})

View File

@ -17,6 +17,7 @@ export default Component.extend({
draftChannelView: equal("view", DRAFT_CHANNEL_VIEW),
chat: service(),
router: service(),
chatChannelsManager: service(),
chatStateManager: service(),
loading: false,
showClose: true, // TODO - false when on same topic
@ -40,7 +41,6 @@ export default Component.extend({
this,
"openChannelAtMessage"
);
this.appEvents.on("chat:refresh-channels", this, "refreshChannels");
this.appEvents.on("composer:closed", this, "_checkSize");
this.appEvents.on("composer:opened", this, "_checkSize");
this.appEvents.on("composer:resized", this, "_checkSize");
@ -68,7 +68,6 @@ export default Component.extend({
this,
"openChannelAtMessage"
);
this.appEvents.off("chat:refresh-channels", this, "refreshChannels");
this.appEvents.off("composer:closed", this, "_checkSize");
this.appEvents.off("composer:opened", this, "_checkSize");
this.appEvents.off("composer:resized", this, "_checkSize");
@ -198,12 +197,9 @@ export default Component.extend({
}
},
@discourseComputed(
"chat.activeChannel",
"currentUser.chat_channel_tracking_state"
)
unreadCount(activeChannel, trackingState) {
return trackingState[activeChannel.id]?.unread_count || 0;
@discourseComputed("chat.activeChannel.currentUserMembership.unread_count")
unreadCount(count) {
return count || 0;
},
@action
@ -218,7 +214,6 @@ export default Component.extend({
switch (route.name) {
case "chat":
this.set("view", LIST_VIEW);
this.refreshChannels();
this.appEvents.trigger("chat:float-toggled", false);
return;
case "chat.draft-channel":
@ -226,8 +221,8 @@ export default Component.extend({
this.appEvents.trigger("chat:float-toggled", false);
return;
case "chat.channel":
return this.chat
.getChannelBy("id", route.params.channelId)
return this.chatChannelsManager
.find(route.params.channelId)
.then((channel) => {
this.chat.set("messageId", route.queryParams.messageId);
this.chat.setActiveChannel(channel);
@ -262,32 +257,6 @@ export default Component.extend({
this.appEvents.trigger("chat:float-toggled", true);
},
@action
refreshChannels() {
if (this.view === LIST_VIEW) {
this.fetchChannels();
}
},
@action
fetchChannels() {
this.set("loading", true);
this.chat.getChannels().then(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.setProperties({
loading: false,
view: LIST_VIEW,
});
this.chatStateManager.didExpandDrawer();
this.chat.setActiveChannel(null);
});
},
@action
switchChannel(channel) {
// we need next here to ensure we correctly let the time for routes transitions

View File

@ -0,0 +1,9 @@
{{#if (gt this.chatChannelsManager.unreadUrgentCount 0)}}
<div class="chat-channel-unread-indicator urgent">
<div class="number-wrap">
<div class="number">{{this.chatChannelsManager.unreadUrgentCount}}</div>
</div>
</div>
{{else if (gt this.chatChannelsManager.unreadCount 0)}}
<div class="chat-channel-unread-indicator"></div>
{{/if}}

View File

@ -0,0 +1,6 @@
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
export default class ChatHeaderIconUnreadIndicator extends Component {
@service chatChannelsManager;
}

View File

@ -0,0 +1,21 @@
{{#if (and this.chatStateManager.isFullPageActive this.site.desktopView)}}
<span class={{concat-class "icon" (if this.isActive "active")}}>
{{d-icon "comment"}}
{{#unless this.currentUserInDnD}}
<ChatHeaderIconUnreadIndicator />
{{/unless}}
</span>
{{else}}
<a
href={{this.href}}
tabindex="0"
class={{concat-class "icon" (if this.isActive "active")}}
>
{{d-icon "comment"}}
{{#unless this.currentUserInDnD}}
<ChatHeaderIconUnreadIndicator />
{{/unless}}
</a>
{{/if}}

View File

@ -0,0 +1,31 @@
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
export default class ChatHeaderIcon extends Component {
@service currentUser;
@service site;
@service chatStateManager;
get currentUserInDnD() {
return this.currentUser.isInDoNotDisturb();
}
get href() {
if (this.chatStateManager.isFullPageActive && this.site.mobileView) {
return "/chat";
}
if (this.chatStateManager.isDrawerActive) {
return "/chat";
} else {
return this.chatStateManager.lastKnownChatURL || "/chat";
}
}
get isActive() {
return (
this.chatStateManager.isFullPageActive ||
this.chatStateManager.isDrawerActive
);
}
}

View File

@ -21,6 +21,7 @@
{{/if}}
</div>
<ChatChannelArchiveStatus @channel={{this.chatChannel}} />
<ChatChannelStatus @channel={{this.chatChannel}} />
</div>
{{/if}}

View File

@ -1,7 +1,5 @@
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import { cloneJSON } from "discourse-common/lib/object";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import Component from "@ember/component";
import discourseComputed, {
@ -83,10 +81,12 @@ export default Component.extend({
_mentionWarningsSeen: null, // Hash
chat: service(),
chatChannelsManager: service(),
router: service(),
chatEmojiPickerManager: service(),
chatComposerPresenceManager: service(),
chatStateManager: service(),
chatApi: service(),
getCachedChannelDetails: null,
clearCachedChannelDetails: null,
@ -546,8 +546,7 @@ export default Component.extend({
},
_getLastReadId() {
return this.currentUser?.chat_channel_tracking_state?.[this.chatChannel.id]
?.chat_message_id;
return this.chatChannel.currentUserMembership.chat_message_id;
},
_markLastReadMessage(opts = { reRender: false }) {
@ -563,7 +562,6 @@ export default Component.extend({
return;
}
this.set("lastSendReadMessageId", lastReadId);
const indexOfLastReadMessage =
this.messages.findIndex((m) => m.id === lastReadId) || 0;
let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1];
@ -1009,7 +1007,8 @@ export default Component.extend({
// Start ajax request but don't return here, we want to stage the message instantly when all messages are loaded.
// Otherwise, we'll fetch latest and scroll to the one we just created.
// Return a resolved promise below.
const msgCreationPromise = ChatApi.sendMessage(this.chatChannel.id, data)
const msgCreationPromise = this.chatApi
.sendMessage(this.chatChannel.id, data)
.catch((error) => {
this._onSendError(data.staged_id, error);
})
@ -1047,33 +1046,25 @@ export default Component.extend({
},
async _upsertChannelWithMessage(channel, message, uploads) {
let promise;
let promise = Promise.resolve(channel);
if (channel.isDirectMessageChannel || channel.isDraft) {
promise = this.chat.upsertDmChannelForUsernames(
channel.chatable.users.mapBy("username")
);
} else {
promise = ChatApi.loading(channel.id).then(() => channel);
}
return promise
.then((c) => {
c.current_user_membership.set("following", true);
return this.chat.startTrackingChannel(c);
return promise.then((c) =>
ajax(`/chat/${c.id}.json`, {
type: "POST",
data: {
message,
upload_ids: (uploads || []).mapBy("id"),
},
}).then(() => {
this.onSwitchChannel(c);
})
.then((c) =>
ajax(`/chat/${c.id}.json`, {
type: "POST",
data: {
message,
upload_ids: (uploads || []).mapBy("id"),
},
}).then(() => {
this.chat.forceRefreshChannels();
this.onSwitchChannel(ChatChannel.create(c));
})
);
);
},
_onSendError(stagedId, error) {
@ -1103,7 +1094,8 @@ export default Component.extend({
staged_id: stagedMessage.stagedId,
};
ChatApi.sendMessage(this.chatChannel.id, data)
this.chatApi
.sendMessage(this.chatChannel.id, data)
.catch((error) => {
this._onSendError(data.staged_id, error);
})

View File

@ -6,11 +6,23 @@
<p>{{this.instructionsText}}</p>
</div>
<ChatChannelChooser @class="chat-move-message-channel-chooser" @content={{this.availableChannels}} @value={{this.destinationChannelId}} @nameProperty="title" />
<ChatChannelChooser
@class="chat-move-message-channel-chooser"
@content={{this.availableChannels}}
@value={{this.destinationChannelId}}
@nameProperty="title"
/>
</DModalBody>
<div class="modal-footer">
<DButton @class="btn-primary" @icon="sign-out-alt" @disabled={{this.disableMoveButton}} @action={{action "moveMessages"}} @label="chat.move_to_channel.confirm_move" @id="chat-confirm-move-messages-to-channel" />
<DButton
@class="btn-primary"
@icon="sign-out-alt"
@disabled={{this.disableMoveButton}}
@action={{action "moveMessages"}}
@label="chat.move_to_channel.confirm_move"
@id="chat-confirm-move-messages-to-channel"
/>
<DButton @label="cancel" @class="btn-flat" @action={{this.closeModal}} />
</div>

View File

@ -3,14 +3,15 @@ import I18n from "I18n";
import { reads } from "@ember/object/computed";
import { isBlank } from "@ember/utils";
import { action, computed } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { htmlSafe } from "@ember/template";
export default class MoveToChannelModalInner extends Component {
@service chat;
@service chatApi;
@service router;
@service chatChannelsManager;
tagName = "";
sourceChannel = null;
destinationChannelId = null;
@ -23,31 +24,25 @@ export default class MoveToChannelModalInner extends Component {
return isBlank(this.destinationChannelId);
}
@computed("chat.publicChannels.[]")
@computed("chatChannelsManager.publicMessageChannels.[]")
get availableChannels() {
return this.chat.publicChannels.rejectBy("id", this.sourceChannel.id);
return this.chatChannelsManager.publicMessageChannels.rejectBy(
"id",
this.sourceChannel.id
);
}
@action
moveMessages() {
return ajax(
`/chat/${this.sourceChannel.id}/move_messages_to_channel.json`,
{
method: "PUT",
data: {
message_ids: this.selectedMessageIds,
destination_channel_id: this.destinationChannelId,
},
}
)
return this.chatApi
.moveChannelMessages(this.sourceChannel.id, {
message_ids: this.selectedMessageIds,
destination_channel_id: this.destinationChannelId,
})
.then((response) => {
this.router.transitionTo(
"chat.channel",
return this.chat.openChannelAtMessage(
response.destination_channel_id,
response.destination_channel_title,
{
queryParams: { messageId: response.first_moved_message_id },
}
response.first_moved_message_id
);
})
.catch(popupAjaxError);

View File

@ -27,6 +27,7 @@
(if this.selectingMessages "selecting-messages")
}}
data-id={{or this.message.id this.message.stagedId}}
data-staged-id={{if this.message.staged this.message.stagedId}}
>
{{#if this.show}}
{{#if this.selectingMessages}}

View File

@ -43,6 +43,7 @@ export default Component.extend({
onHoverMessage: null,
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
chatChannelsManager: service("chat-channels-manager"),
adminTools: optionalService(),
_hasSubscribedToAppEvents: false,
tagName: "",
@ -589,13 +590,11 @@ export default Component.extend({
// so we will fully refresh if we were not members of the channel
// already
if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) {
this.chat.forceRefreshChannels().then(() => {
return this.chat
.getChannelBy("id", this.chatChannel.id)
.then((reactedChannel) => {
this.onSwitchChannel(reactedChannel);
});
});
return this.chatChannelsManager
.getChannel(this.chatChannel.id)
.then((reactedChannel) => {
this.onSwitchChannel(reactedChannel);
});
}
});
},

View File

@ -3,9 +3,9 @@ import { htmlSafe } from "@ember/template";
import discourseComputed from "discourse-common/utils/decorators";
import { alias, equal } from "@ember/object/computed";
export const NEW_TOPIC_SELECTION = "newTopic";
export const EXISTING_TOPIC_SELECTION = "existingTopic";
export const NEW_MESSAGE_SELECTION = "newMessage";
export const NEW_TOPIC_SELECTION = "new_topic";
export const EXISTING_TOPIC_SELECTION = "existing_topic";
export const NEW_MESSAGE_SELECTION = "new_message";
export default Component.extend({
newTopicSelection: NEW_TOPIC_SELECTION,

View File

@ -10,9 +10,6 @@ export default Component.extend({
init() {
this._super(...arguments);
this.appEvents.on("chat:refresh-channels", this, "refreshModel");
this.appEvents.on("chat:refresh-channel", this, "_refreshChannel");
},
didInsertElement() {
@ -25,8 +22,6 @@ export default Component.extend({
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat:refresh-channels", this, "refreshModel");
this.appEvents.off("chat:refresh-channel", this, "_refreshChannel");
document.removeEventListener("keydown", this._autoFocusChatComposer);
},
@ -77,12 +72,6 @@ export default Component.extend({
}
},
_refreshChannel(channelId) {
if (this.chat.activeChannel?.id === channelId) {
this.refreshModel(true);
}
},
@action
navigateToIndex() {
this.router.transitionTo("chat.index");

View File

@ -1,4 +1,4 @@
{{#if this.channel.isFollowing}}
{{#if @channel.currentUserMembership.following}}
<DButton
@action={{action "onLeaveChannel"}}
@translatedLabel={{this.label}}

View File

@ -1,53 +1,44 @@
import Component from "@ember/component";
import Component from "@glimmer/component";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { action, computed } from "@ember/object";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class ToggleChannelMembershipButton extends Component {
@service chat;
tagName = "";
channel = null;
@tracked isLoading = false;
onToggle = null;
options = null;
isLoading = false;
options = {};
init() {
super.init(...arguments);
constructor() {
super(...arguments);
this.set(
"options",
Object.assign(
{
labelType: "normal",
joinTitle: I18n.t("chat.channel_settings.join_channel"),
joinIcon: "",
joinClass: "",
leaveTitle: I18n.t("chat.channel_settings.leave_channel"),
leaveIcon: "",
leaveClass: "",
},
this.options || {}
)
);
this.options = {
labelType: "normal",
joinTitle: I18n.t("chat.channel_settings.join_channel"),
joinIcon: "",
joinClass: "",
leaveTitle: I18n.t("chat.channel_settings.leave_channel"),
leaveIcon: "",
leaveClass: "",
...this.args.options,
};
}
@computed("channel.current_user_membership.following")
get label() {
if (this.options.labelType === "none") {
return "";
}
if (this.options.labelType === "short") {
if (this.channel.isFollowing) {
if (this.args.channel.currentUserMembership.following) {
return I18n.t("chat.channel_settings.leave");
} else {
return I18n.t("chat.channel_settings.join");
}
}
if (this.channel.isFollowing) {
if (this.args.channel.currentUserMembership.following) {
return I18n.t("chat.channel_settings.leave_channel");
} else {
return I18n.t("chat.channel_settings.join_channel");
@ -56,10 +47,10 @@ export default class ToggleChannelMembershipButton extends Component {
@action
onJoinChannel() {
this.set("isLoading", true);
this.isLoading = true;
return this.chat
.followChannel(this.channel)
.followChannel(this.args.channel)
.then(() => {
this.onToggle?.();
})
@ -69,16 +60,16 @@ export default class ToggleChannelMembershipButton extends Component {
return;
}
this.set("isLoading", false);
this.isLoading = false;
});
}
@action
onLeaveChannel() {
this.set("isLoading", true);
this.isLoading = true;
return this.chat
.unfollowChannel(this.channel)
.unfollowChannel(this.args.channel)
.then(() => {
this.onToggle?.();
})
@ -88,7 +79,7 @@ export default class ToggleChannelMembershipButton extends Component {
return;
}
this.set("isLoading", false);
this.isLoading = false;
});
}
}

View File

@ -1,11 +1,12 @@
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import { inject as service } from "@ember/service";
export default class ChatChannelEditDescriptionController extends Controller.extend(
ModalFunctionality
) {
@service chatApi;
editedDescription = "";
@computed("model.description", "editedDescription")
@ -27,11 +28,12 @@ export default class ChatChannelEditDescriptionController extends Controller.ext
@action
onSaveChatChannelDescription() {
return ChatApi.modifyChatChannel(this.model.id, {
description: this.editedDescription,
})
.then((chatChannel) => {
this.model.set("description", chatChannel.description);
return this.chatApi
.updateChannel(this.model.id, {
description: this.editedDescription,
})
.then((result) => {
this.model.set("description", result.channel.description);
this.send("closeModal");
})
.catch((event) => {

View File

@ -1,11 +1,11 @@
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import { inject as service } from "@ember/service";
export default class ChatChannelEditTitleController extends Controller.extend(
ModalFunctionality
) {
@service chatApi;
editedTitle = "";
@computed("model.title", "editedTitle")
@ -27,11 +27,12 @@ export default class ChatChannelEditTitleController extends Controller.extend(
@action
onSaveChatChannelTitle() {
return ChatApi.modifyChatChannel(this.model.id, {
name: this.editedTitle,
})
.then((chatChannel) => {
this.model.set("title", chatChannel.title);
return this.chatApi
.updateChannel(this.model.id, {
name: this.editedTitle,
})
.then((result) => {
this.model.set("title", result.channel.title);
this.send("closeModal");
})
.catch((event) => {

View File

@ -8,13 +8,13 @@ export default class ChatChannelInfoAboutController extends Controller.extend(
) {
@action
onEditChatChannelTitle() {
showModal("chat-channel-edit-title", { model: this.model?.chatChannel });
showModal("chat-channel-edit-title", { model: this.model });
}
@action
onEditChatChannelDescription() {
showModal("chat-channel-edit-description", {
model: this.model?.chatChannel,
model: this.model,
});
}
}

View File

@ -1,7 +1,7 @@
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import { inject as service } from "@ember/service";
import { reads } from "@ember/object/computed";
import { computed } from "@ember/object";
export default class ChatChannelInfoIndexController extends Controller {
@service router;
@ -10,28 +10,25 @@ export default class ChatChannelInfoIndexController extends Controller {
@reads("router.currentRoute.localName") tab;
@computed("model.chatChannel.{membershipsCount,status}")
@computed("model.{membershipsCount,status,currentUserMembership.following}")
get tabs() {
const tabs = [];
if (!this.model.chatChannel.isDirectMessageChannel) {
if (!this.model.isDirectMessageChannel) {
tabs.push("about");
}
if (
this.model.chatChannel.isOpen &&
this.model.chatChannel.membershipsCount >= 1
) {
if (this.model.isOpen && this.model.membershipsCount >= 1) {
tabs.push("members");
}
tabs.push("settings");
if (
this.currentUser?.staff ||
this.model.currentUserMembership?.following
) {
tabs.push("settings");
}
return tabs;
}
@action
switchChannel(channel) {
return this.chat.openChannel(channel);
}
}

View File

@ -1,10 +1,7 @@
import { escapeExpression } from "discourse/lib/utilities";
import Controller from "@ember/controller";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { ajax } from "discourse/lib/ajax";
import { action, computed } from "@ember/object";
import { gt, notEmpty } from "@ember/object/computed";
import { inject as service } from "@ember/service";
@ -23,6 +20,8 @@ export default class CreateChannelController extends Controller.extend(
) {
@service chat;
@service dialog;
@service chatChannelsManager;
@service chatApi;
category = null;
categoryId = null;
@ -57,20 +56,18 @@ export default class CreateChannelController extends Controller.extend(
_createChannel() {
const data = {
id: this.categoryId,
chatable_id: this.categoryId,
name: this.name,
description: this.description,
auto_join_users: this.autoJoinUsers,
};
return ajax("/chat/chat_channels", { method: "PUT", data })
.then((response) => {
const chatChannel = ChatChannel.create(response.chat_channel);
return this.chat.startTrackingChannel(chatChannel).then(() => {
this.send("closeModal");
this.chat.openChannel(chatChannel);
});
return this.chatApi
.createChannel(data)
.then((channel) => {
this.send("closeModal");
this.chatChannelsManager.follow(channel);
this.chat.openChannel(channel);
})
.catch((e) => {
this.flash(e.jqXHR.responseJSON.errors[0], "error");
@ -117,24 +114,26 @@ export default class CreateChannelController extends Controller.extend(
if (category) {
const fullSlug = this._buildCategorySlug(category);
return ChatApi.categoryPermissions(category.id).then((catPermissions) => {
this._updateAutoJoinConfirmWarning(category, catPermissions);
const allowedGroups = catPermissions.allowed_groups;
const translationKey =
allowedGroups.length < 3 ? "hint_groups" : "hint_multiple_groups";
return this.chatApi
.categoryPermissions(category.id)
.then((catPermissions) => {
this._updateAutoJoinConfirmWarning(category, catPermissions);
const allowedGroups = catPermissions.allowed_groups;
const translationKey =
allowedGroups.length < 3 ? "hint_groups" : "hint_multiple_groups";
this.set(
"categoryPermissionsHint",
htmlSafe(
I18n.t(`chat.create_channel.choose_category.${translationKey}`, {
link: `/c/${escapeExpression(fullSlug)}/edit/security`,
hint: escapeExpression(allowedGroups[0]),
hint_2: escapeExpression(allowedGroups[1]),
count: allowedGroups.length,
})
)
);
});
this.set(
"categoryPermissionsHint",
htmlSafe(
I18n.t(`chat.create_channel.choose_category.${translationKey}`, {
link: `/c/${escapeExpression(fullSlug)}/edit/security`,
hint: escapeExpression(allowedGroups[0]),
hint_2: escapeExpression(allowedGroups[1]),
count: allowedGroups.length,
})
)
);
});
} else {
this.set("categoryPermissionsHint", DEFAULT_HINT);
this.set("autoJoinWarning", "");

View File

@ -12,15 +12,13 @@ export default {
name: "chat-setup",
initialize(container) {
this.chatService = container.lookup("service:chat");
if (!this.chatService.userCanChat) {
return;
}
this.siteSettings = container.lookup("service:site-settings");
this.appEvents = container.lookup("service:appEvents");
this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged");
if (!this.chatService.userCanChat) {
return;
}
withPluginApi("0.12.1", (api) => {
api.registerChatComposerButton({
id: "chat-upload-btn",
@ -99,8 +97,6 @@ export default {
const currentUser = api.getCurrentUser();
if (currentUser?.chat_channels) {
this.chatService.setupWithPreloadedChannels(currentUser.chat_channels);
} else {
this.chatService.setupWithoutPreloadedChannels();
}
const chatNotificationManager = container.lookup(
@ -115,19 +111,7 @@ export default {
api.addCardClickListenerSelector(".chat-drawer-outlet");
api.dispatchWidgetAppEvent(
"site-header",
"header-chat-link",
"chat:rerender-header"
);
api.dispatchWidgetAppEvent(
"sidebar-header",
"header-chat-link",
"chat:rerender-header"
);
api.addToHeaderIcons("header-chat-link");
api.addToHeaderIcons("chat-header-icon");
api.decorateChatMessage(function (chatMessage, chatChannel) {
if (!this.currentUser) {
@ -155,17 +139,22 @@ export default {
},
teardown() {
this.appEvents.off("discourse:focus-changed", this, "_handleFocusChanged");
if (!this.chatService.userCanChat) {
return;
}
this.appEvents.off("discourse:focus-changed", this, "_handleFocusChanged");
_lastForcedRefreshAt = null;
clearChatComposerButtons();
},
@bind
_handleFocusChanged(hasFocus) {
if (!this.chatService.userCanChat) {
return;
}
if (!hasFocus) {
_lastForcedRefreshAt = Date.now();
return;
@ -179,6 +168,5 @@ export default {
}
_lastForcedRefreshAt = Date.now();
this.chatService.refreshTrackingState();
},
};

View File

@ -25,41 +25,12 @@ export default {
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
const SidebarChatChannelsSectionLink = class extends BaseCustomSidebarSectionLink {
@tracked chatChannelTrackingState =
this.chatService.currentUser.chat_channel_tracking_state[
this.channel.id
];
constructor({ channel, chatService }) {
super(...arguments);
this.channel = channel;
this.chatService = chatService;
}
@bind
willDestroy() {
this.chatService.appEvents.off(
"chat:user-tracking-state-changed",
this._refreshTrackingState
);
}
@bind
didInsert() {
this.chatService.appEvents.on(
"chat:user-tracking-state-changed",
this._refreshTrackingState
);
}
@bind
_refreshTrackingState() {
this.chatChannelTrackingState =
this.chatService.currentUser.chat_channel_tracking_state[
this.channel.id
];
}
get name() {
return dasherize(slugifyChannel(this.channel));
}
@ -68,7 +39,7 @@ export default {
get classNames() {
const classes = [];
if (this.channel.current_user_membership.muted) {
if (this.channel.currentUserMembership.muted) {
classes.push("sidebar-section-link--muted");
}
@ -76,6 +47,8 @@ export default {
classes.push("sidebar-section-link--active");
}
classes.push(`channel-${this.channel.id}`);
return classes.join(" ");
}
@ -118,26 +91,19 @@ export default {
}
get suffixValue() {
return this.chatChannelTrackingState?.unread_count > 0
return this.channel.currentUserMembership.unread_count > 0
? "circle"
: "";
}
get suffixCSSClass() {
return this.chatChannelTrackingState?.unread_mentions > 0
return this.channel.currentUserMembership.unread_mentions > 0
? "urgent"
: "unread";
}
};
const SidebarChatChannelsSection = class extends BaseCustomSidebarSection {
@tracked sectionLinks = [];
@tracked sectionIndicator =
this.chatService.publicChannels &&
this.chatService.publicChannels[0].current_user_membership
.unread_count;
@tracked currentUserCanJoinPublicChannels =
this.sidebar.currentUser &&
(this.sidebar.currentUser.staff ||
@ -150,37 +116,20 @@ export default {
return;
}
this.chatService = container.lookup("service:chat");
this.router = container.lookup("service:router");
this.appEvents = container.lookup("service:app-events");
this.appEvents.on("chat:refresh-channels", this._refreshChannels);
this._refreshChannels();
}
@bind
willDestroy() {
if (!this.appEvents) {
return;
}
this.appEvents.off(
"chat:refresh-channels",
this._refreshChannels
this.chatChannelsManager = container.lookup(
"service:chat-channels-manager"
);
this.router = container.lookup("service:router");
}
@bind
_refreshChannels() {
const newSectionLinks = [];
this.chatService.getChannels().then((channels) => {
channels.publicChannels.forEach((channel) => {
newSectionLinks.push(
new SidebarChatChannelsSectionLink({
channel,
chatService: this.chatService,
})
);
});
this.sectionLinks = newSectionLinks;
});
get sectionLinks() {
return this.chatChannelsManager.publicMessageChannels.map(
(channel) =>
new SidebarChatChannelsSectionLink({
channel,
chatService: this.chatService,
})
);
}
get name() {
@ -228,11 +177,6 @@ export default {
api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
const SidebarChatDirectMessagesSectionLink = class extends BaseCustomSidebarSectionLink {
@tracked chatChannelTrackingState =
this.chatService.currentUser.chat_channel_tracking_state[
this.channel.id
];
constructor({ channel, chatService }) {
super(...arguments);
this.channel = channel;
@ -258,7 +202,7 @@ export default {
get classNames() {
const classes = [];
if (this.channel.current_user_membership.muted) {
if (this.channel.currentUserMembership.muted) {
classes.push("sidebar-section-link--muted");
}
@ -266,6 +210,8 @@ export default {
classes.push("sidebar-section-link--active");
}
classes.push(`channel-${this.channel.id}`);
return classes.join(" ");
}
@ -340,7 +286,7 @@ export default {
}
get suffixValue() {
return this.chatChannelTrackingState?.unread_count > 0
return this.channel.currentUserMembership.unread_count > 0
? "circle"
: "";
}
@ -396,7 +342,6 @@ export default {
const SidebarChatDirectMessagesSection = class extends BaseCustomSidebarSection {
@service site;
@service router;
@tracked sectionLinks = [];
@tracked userCanDirectMessage =
this.chatService.userCanDirectMessage;
@ -407,40 +352,19 @@ export default {
return;
}
this.chatService = container.lookup("service:chat");
this.chatService.appEvents.on(
"chat:user-tracking-state-changed",
this._refreshDirectMessageChannels
);
this._refreshDirectMessageChannels();
}
@bind
willDestroy() {
if (container.isDestroyed) {
return;
}
this.chatService.appEvents.off(
"chat:user-tracking-state-changed",
this._refreshDirectMessageChannels
this.chatChannelsManager = container.lookup(
"service:chat-channels-manager"
);
}
@bind
_refreshDirectMessageChannels() {
const newSectionLinks = [];
this.chatService.getChannels().then((channels) => {
this.chatService
.truncateDirectMessageChannels(channels.directMessageChannels)
.forEach((channel) => {
newSectionLinks.push(
new SidebarChatDirectMessagesSectionLink({
channel,
chatService: this.chatService,
})
);
});
this.sectionLinks = newSectionLinks;
});
get sectionLinks() {
return this.chatChannelsManager.truncatedDirectMessageChannels.map(
(channel) =>
new SidebarChatDirectMessagesSectionLink({
channel,
chatService: this.chatService,
})
);
}
get name() {

View File

@ -1,95 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class ChatApi {
static async chatChannelMemberships(channelId, data) {
return await ajax(`/chat/api/chat_channels/${channelId}/memberships.json`, {
data,
}).catch(popupAjaxError);
}
static async updateChatChannelNotificationsSettings(channelId, data = {}) {
return await ajax(
`/chat/api/chat_channels/${channelId}/notifications_settings.json`,
{
method: "PUT",
data,
}
).catch(popupAjaxError);
}
static async sendMessage(channelId, data = {}) {
return ajax(`/chat/${channelId}.json`, {
ignoreUnsent: false,
method: "POST",
data,
});
}
static async chatChannels(data = {}) {
if (data?.status === "all") {
delete data.status;
}
return await ajax(`/chat/api/chat_channels.json`, {
method: "GET",
data,
})
.then((channels) =>
channels.map((channel) => ChatChannel.create(channel))
)
.catch(popupAjaxError);
}
static async modifyChatChannel(channelId, data) {
return await this._performRequest(
`/chat/api/chat_channels/${channelId}.json`,
{
method: "PUT",
data,
}
);
}
static async unfollowChatChannel(channel) {
return await this._performRequest(
`/chat/chat_channels/${channel.id}/unfollow.json`,
{
method: "POST",
}
).then((updatedChannel) => {
channel.updateMembership(updatedChannel.current_user_membership);
// doesn't matter if this is inaccurate, it will be eventually consistent
// via the channel-metadata MessageBus channel
channel.set("memberships_count", channel.memberships_count - 1);
return channel;
});
}
static async followChatChannel(channel) {
return await this._performRequest(
`/chat/chat_channels/${channel.id}/follow.json`,
{
method: "POST",
}
).then((updatedChannel) => {
channel.updateMembership(updatedChannel.current_user_membership);
// doesn't matter if this is inaccurate, it will be eventually consistent
// via the channel-metadata MessageBus channel
channel.set("memberships_count", channel.memberships_count + 1);
return channel;
});
}
static async categoryPermissions(categoryId) {
return await this._performRequest(
`/chat/api/category-chatables/${categoryId}/permissions.json`
);
}
static async _performRequest(...args) {
return await ajax(...args).catch(popupAjaxError);
}
}

View File

@ -1,15 +1,16 @@
import RestModel from "discourse/models/rest";
import I18n from "I18n";
import { computed } from "@ember/object";
import User from "discourse/models/user";
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
import { ajax } from "discourse/lib/ajax";
import { escapeExpression } from "discourse/lib/utilities";
import { tracked } from "@glimmer/tracking";
export const CHATABLE_TYPES = {
directMessageChannel: "DirectMessage",
categoryChannel: "Category",
};
export const CHANNEL_STATUSES = {
open: "open",
readOnly: "read_only",
@ -38,13 +39,10 @@ export function channelStatusIcon(channelStatus) {
switch (channelStatus) {
case CHANNEL_STATUSES.closed:
return "lock";
break;
case CHANNEL_STATUSES.readOnly:
return "comment-slash";
break;
case CHANNEL_STATUSES.archived:
return "archive";
break;
}
}
@ -60,62 +58,51 @@ const READONLY_STATUSES = [
];
export default class ChatChannel extends RestModel {
isDraft = false;
lastSendReadMessageId = null;
@tracked currentUserMembership = null;
@tracked isDraft = false;
@tracked title;
@tracked description;
@tracked chatableType;
@tracked status;
@computed("title")
get escapedTitle() {
return escapeExpression(this.title);
}
@computed("description")
get escapedDescription() {
return escapeExpression(this.description);
}
@computed("chatable_type")
get isDirectMessageChannel() {
return this.chatable_type === CHATABLE_TYPES.directMessageChannel;
}
@computed("chatable_type")
get isCategoryChannel() {
return this.chatable_type === CHATABLE_TYPES.categoryChannel;
}
@computed("status")
get isOpen() {
return !this.status || this.status === CHANNEL_STATUSES.open;
}
@computed("status")
get isReadOnly() {
return this.status === CHANNEL_STATUSES.readOnly;
}
@computed("status")
get isClosed() {
return this.status === CHANNEL_STATUSES.closed;
}
@computed("status")
get isArchived() {
return this.status === CHANNEL_STATUSES.archived;
}
@computed("isArchived", "isOpen")
get isJoinable() {
return this.isOpen && !this.isArchived;
}
@computed("memberships_count")
get membershipsCount() {
return this.memberships_count;
}
@computed("current_user_membership.following")
get isFollowing() {
return this.current_user_membership.following;
return this.currentUserMembership.following;
}
canModifyMessages(user) {
@ -127,12 +114,12 @@ export default class ChatChannel extends RestModel {
}
updateMembership(membership) {
this.current_user_membership.setProperties({
following: membership.following,
muted: membership.muted,
desktop_notification_level: membership.desktop_notification_level,
mobile_notification_level: membership.mobile_notification_level,
});
this.currentUserMembership.following = membership.following;
this.currentUserMembership.muted = membership.muted;
this.currentUserMembership.desktop_notification_level =
membership.desktop_notification_level;
this.currentUserMembership.mobile_notification_level =
membership.mobile_notification_level;
}
updateLastReadMessage(messageId) {
@ -143,7 +130,7 @@ export default class ChatChannel extends RestModel {
return ajax(`/chat/${this.id}/read/${messageId}.json`, {
method: "PUT",
}).then(() => {
this.set("lastSendReadMessageId", messageId);
this.currentUserMembership.last_read_message_id = messageId;
});
}
}
@ -151,11 +138,12 @@ export default class ChatChannel extends RestModel {
ChatChannel.reopenClass({
create(args) {
args = args || {};
this._initUserModels(args);
this._initUserMembership(args);
args.lastSendReadMessageId =
args.current_user_membership?.last_read_message_id;
args.chatableType = args.chatable_type;
args.membershipsCount = args.memberships_count;
return this._super(args);
},
@ -170,11 +158,11 @@ ChatChannel.reopenClass({
},
_initUserMembership(args) {
if (args.current_user_membership instanceof UserChatChannelMembership) {
if (args.currentUserMembership instanceof UserChatChannelMembership) {
return;
}
args.current_user_membership = UserChatChannelMembership.create(
args.currentUserMembership = UserChatChannelMembership.create(
args.current_user_membership || {
following: false,
muted: false,
@ -182,6 +170,8 @@ ChatChannel.reopenClass({
unread_mentions: 0,
}
);
delete args.current_user_membership;
},
});

View File

@ -1,3 +1,30 @@
import RestModel from "discourse/models/rest";
import { tracked } from "@glimmer/tracking";
import User from "discourse/models/user";
export default class UserChatChannelMembership extends RestModel {
@tracked following = false;
@tracked muted = false;
@tracked unread_count = 0;
@tracked unread_mentions = 0;
@tracked chat_message_id = null;
@tracked chat_channel_id = null;
@tracked desktop_notification_level = null;
@tracked mobile_notification_level = null;
@tracked last_read_message_id = null;
}
export default class UserChatChannelMembership extends RestModel {}
UserChatChannelMembership.reopenClass({
create(args) {
args = args || {};
this._initUser(args);
return this._super(args);
},
_initUser(args) {
if (args.user instanceof User) {
return;
}
args.user = User.create(args.user);
},
});

View File

@ -0,0 +1,9 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class ChatBrowseIndexRoute extends DiscourseRoute {
afterModel() {
if (!this.siteSettings.chat_allow_archiving_channels) {
this.replaceWith("chat.browse");
}
}
}

View File

@ -13,8 +13,8 @@ export default class ChatChannelByNameRoute extends DiscourseRoute {
.then((response) => {
this.transitionTo(
"chat.channel",
response.chat_channel.id,
response.chat_channel.title
response.channel.id,
response.channel.title
);
})
.catch(() => this.replaceWith("/404"));

View File

@ -2,7 +2,7 @@ import DiscourseRoute from "discourse/routes/discourse";
export default class ChatChannelInfoAboutRoute extends DiscourseRoute {
afterModel(model) {
if (model.chatChannel.isDirectMessageChannel) {
if (model.isDirectMessageChannel) {
this.replaceWith("chat.channel.info.index");
}
}

View File

@ -2,8 +2,8 @@ import DiscourseRoute from "discourse/routes/discourse";
export default class ChatChannelInfoIndexRoute extends DiscourseRoute {
afterModel(model) {
if (model.chatChannel.isDirectMessageChannel) {
if (model.chatChannel.isOpen && model.chatChannel.membershipsCount >= 1) {
if (model.isDirectMessageChannel) {
if (model.isOpen && model.membershipsCount >= 1) {
this.replaceWith("chat.channel.info.members");
} else {
this.replaceWith("chat.channel.info.settings");

View File

@ -2,8 +2,12 @@ import DiscourseRoute from "discourse/routes/discourse";
export default class ChatChannelInfoMembersRoute extends DiscourseRoute {
afterModel(model) {
if (!model.chatChannel.isOpen) {
this.replaceWith("chat.channel.info.settings");
if (!model.isOpen) {
return this.replaceWith("chat.channel.info.settings");
}
if (model.membershipsCount < 1) {
return this.replaceWith("chat.channel.info");
}
}
}

View File

@ -0,0 +1,9 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class ChatChannelInfoSettingsRoute extends DiscourseRoute {
afterModel(model) {
if (!this.currentUser?.staff && !model.currentUserMembership?.following) {
this.replaceWith("chat.channel.info");
}
}
}

View File

@ -1,48 +1,23 @@
import DiscourseRoute from "discourse/routes/discourse";
import Promise from "rsvp";
import EmberObject, { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { inject as service } from "@ember/service";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel";
export default class ChatChannelRoute extends DiscourseRoute {
@service chat;
@service router;
@service chatChannelsManager;
async model(params) {
let [chatChannel, channels] = await Promise.all([
this.getChannel(params.channelId),
this.chat.getChannels(),
]);
return EmberObject.create({
chatChannel,
channels,
});
}
async getChannel(id) {
let channel = await this.chat.getChannelBy("id", id);
if (!channel || this.forceRefetchChannel) {
channel = await this.getChannelFromServer(id);
}
return channel;
}
async getChannelFromServer(id) {
return ajax(`/chat/chat_channels/${id}`)
.then((response) => ChatChannel.create(response))
.catch(() => this.replaceWith("/404"));
return this.chatChannelsManager.find(params.channelId);
}
afterModel(model) {
this.chat.setActiveChannel(model?.chatChannel);
this.chat.setActiveChannel(model);
const queryParams = this.paramsFor(this.routeName);
const slug = slugifyChannel(model.chatChannel);
const slug = slugifyChannel(model);
if (queryParams?.channelTitle !== slug) {
this.router.replaceWith("chat.channel.index", model.chatChannel.id, slug);
this.router.replaceWith("chat.channel.index", model.id, slug);
}
}
@ -54,10 +29,4 @@ export default class ChatChannelRoute extends DiscourseRoute {
this.controller.set("messageId", null);
}
}
@action
refreshModel(forceRefetchChannel = false) {
this.forceRefetchChannel = forceRefetchChannel;
this.refresh();
}
}

View File

@ -3,36 +3,29 @@ import { inject as service } from "@ember/service";
export default class ChatIndexRoute extends DiscourseRoute {
@service chat;
@service chatChannelsManager;
@service router;
redirect() {
// Always want the channel index on mobile.
if (this.site.mobileView) {
return; // Always want the channel index on mobile.
return;
}
// We are on desktop. Check for a channel to enter and transition if so.
// Otherwise, `setupController` will fetch all available
return this.chat.getIdealFirstChannelIdAndTitle().then((channelInfo) => {
if (channelInfo) {
return this.chat.getChannelBy("id", channelInfo.id).then((c) => {
return this.chat.openChannel(c);
});
} else {
return this.router.transitionTo("chat.browse");
}
});
// We are on desktop. Check for a channel to enter and transition if so
const id = this.chat.getIdealFirstChannelId();
if (id) {
return this.chatChannelsManager.find(id).then((c) => {
return this.chat.openChannel(c);
});
} else {
return this.router.transitionTo("chat.browse");
}
}
model() {
if (this.site.mobileView) {
return this.chat.getChannels().then((channels) => {
if (
channels.publicChannels.length ||
channels.directMessageChannels.length
) {
return channels;
}
});
return this.chatChannelsManager.channels;
}
}
}

View File

@ -0,0 +1,242 @@
import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
import { tracked } from "@glimmer/tracking";
import { bind } from "discourse-common/utils/decorators";
import { Promise } from "rsvp";
class Collection {
@tracked items = [];
@tracked meta = {};
@tracked loading = false;
constructor(resourceURL, handler) {
this._resourceURL = resourceURL;
this._handler = handler;
this._fetchedAll = false;
}
get loadMoreURL() {
return this.meta.load_more_url;
}
get totalRows() {
return this.meta.total_rows;
}
get length() {
return this.items.length;
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < this.items.length) {
return { value: this.items[index++], done: false };
} else {
return { done: true };
}
},
};
}
@bind
load(params = {}) {
this._fetchedAll = false;
if (this.loading) {
return;
}
this.loading = true;
const filteredQueryParams = Object.entries(params).filter(
([, v]) => v !== undefined
);
const queryString = new URLSearchParams(filteredQueryParams).toString();
const endpoint = this._resourceURL + (queryString ? `?${queryString}` : "");
return this.#fetch(endpoint)
.then((result) => {
this.items = this._handler(result);
this.meta = result.meta;
})
.finally(() => {
this.loading = false;
});
}
@bind
loadMore() {
if (this.loading) {
return;
}
if (
this._fetchedAll ||
(this.totalRows && this.items.length >= this.totalRows)
) {
return;
}
let promise;
this.loading = true;
if (this.loadMoreURL) {
promise = this.#fetch(this.loadMoreURL).then((result) => {
const newItems = this._handler(result);
if (newItems.length) {
this.items = this.items.concat(newItems);
} else {
this._fetchedAll = true;
}
this.meta = result.meta;
});
} else {
promise = Promise.resolve();
}
return promise.finally(() => {
this.loading = false;
});
}
#fetch(url) {
return ajax(url, { type: "GET" });
}
}
export default class ChatApi extends Service {
@service chatChannelsManager;
getChannel(channelId) {
return this.#getRequest(`/channels/${channelId}`).then((result) =>
this.chatChannelsManager.store(result.channel)
);
}
channels() {
return new Collection(`${this.#basePath}/channels`, (response) => {
return response.channels.map((channel) =>
this.chatChannelsManager.store(channel)
);
});
}
moveChannelMessages(channelId, data = {}) {
return this.#postRequest(`/channels/${channelId}/messages/moves`, {
move: data,
});
}
destroyChannel(channelId, data = {}) {
return this.#deleteRequest(`/channels/${channelId}`, { channel: data });
}
createChannel(data = {}) {
return this.#postRequest("/channels", { channel: data }).then((response) =>
this.chatChannelsManager.store(response.channel)
);
}
categoryPermissions(categoryId) {
return ajax(`/chat/api/category-chatables/${categoryId}/permissions`);
}
sendMessage(channelId, data = {}) {
return ajax(`/chat/${channelId}`, {
ignoreUnsent: false,
type: "POST",
data,
});
}
createChannelArchive(channelId, data = {}) {
return this.#postRequest(`/channels/${channelId}/archives`, {
archive: data,
});
}
updateChannel(channelId, data = {}) {
return this.#putRequest(`/channels/${channelId}`, { channel: data });
}
updateChannelStatus(channelId, status) {
return this.#putRequest(`/channels/${channelId}/status`, { status });
}
listChannelMemberships(channelId) {
return new Collection(
`${this.#basePath}/channels/${channelId}/memberships`,
(response) => {
return response.memberships.map((membership) =>
UserChatChannelMembership.create(membership)
);
}
);
}
listCurrentUserChannels() {
return this.#getRequest(`/channels/me`).then((result) => {
return (result?.channels || []).map((channel) =>
this.chatChannelsManager.store(channel)
);
});
}
followChannel(channelId) {
return this.#postRequest(`/channels/${channelId}/memberships/me`).then(
(result) => UserChatChannelMembership.create(result.membership)
);
}
unfollowChannel(channelId) {
return this.#deleteRequest(`/channels/${channelId}/memberships/me`).then(
(result) => UserChatChannelMembership.create(result.membership)
);
}
updateCurrentUserChatChannelNotificationsSettings(channelId, data = {}) {
return this.#putRequest(
`/channels/${channelId}/notifications-settings/me`,
{ notifications_settings: data }
);
}
get #basePath() {
return "/chat/api";
}
#getRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}/${endpoint}`, {
type: "GET",
data,
});
}
#putRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}/${endpoint}`, {
type: "PUT",
data,
});
}
#postRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}/${endpoint}`, {
type: "POST",
data,
});
}
#deleteRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}/${endpoint}`, {
type: "DELETE",
data,
});
}
}

View File

@ -0,0 +1,136 @@
import Service, { inject as service } from "@ember/service";
import Promise from "rsvp";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
const DIRECT_MESSAGE_CHANNELS_LIMIT = 20;
export default class ChatChannelsManager extends Service {
@service chatSubscriptionsManager;
@service chatApi;
@service currentUser;
@tracked _cached = new TrackedObject();
get channels() {
return Object.values(this._cached);
}
async find(id) {
const existingChannel = this.#findStale(id);
if (existingChannel) {
return Promise.resolve(existingChannel);
} else {
return this.#find(id);
}
}
store(channelObject) {
let model = this.#findStale(channelObject.id);
if (!model) {
model = ChatChannel.create(channelObject);
this.#cache(model);
}
return model;
}
async follow(model) {
this.chatSubscriptionsManager.startChannelSubscription(model);
if (!model.currentUserMembership.following) {
return this.chatApi.followChannel(model.id).then((membership) => {
model.currentUserMembership.following = membership.following;
model.currentUserMembership.muted = membership.muted;
model.currentUserMembership.desktop_notification_level =
membership.desktop_notification_level;
model.currentUserMembership.mobile_notification_level =
membership.mobile_notification_level;
return model;
});
} else {
return Promise.resolve(model);
}
}
async unfollow(model) {
this.chatSubscriptionsManager.stopChannelSubscription(model);
return this.chatApi.unfollowChannel(model.id).then((membership) => {
model.currentUserMembership = membership;
return model;
});
}
get unreadCount() {
let count = 0;
this.publicMessageChannels.forEach((channel) => {
count += channel.currentUserMembership.unread_count || 0;
});
return count;
}
get unreadUrgentCount() {
let count = 0;
this.channels.forEach((channel) => {
if (channel.isDirectMessageChannel) {
count += channel.currentUserMembership.unread_count || 0;
}
count += channel.currentUserMembership.unread_mentions || 0;
});
return count;
}
get publicMessageChannels() {
return this.channels.filter(
(channel) =>
channel.isCategoryChannel && channel.currentUserMembership.following
);
}
get directMessageChannels() {
return this.#sortDirectMessageChannels(
this.channels.filter((channel) => {
const membership = channel.currentUserMembership;
return channel.isDirectMessageChannel && membership.following;
})
);
}
get truncatedDirectMessageChannels() {
return this.directMessageChannels.slice(0, DIRECT_MESSAGE_CHANNELS_LIMIT);
}
async #find(id) {
return this.chatApi.getChannel(id).then((channel) => {
this.#cache(channel);
return channel;
});
}
#cache(channel) {
this._cached[channel.id] = channel;
}
#findStale(id) {
return this._cached[id];
}
#sortDirectMessageChannels(channels) {
return channels.sort((a, b) => {
const unreadCountA = a.currentUserMembership.unread_count || 0;
const unreadCountB = b.currentUserMembership.unread_count || 0;
if (unreadCountA === unreadCountB) {
return new Date(a.get("last_message_sent_at")) >
new Date(b.get("last_message_sent_at"))
? -1
: 1;
} else {
return unreadCountA > unreadCountB ? -1 : 1;
}
});
}
}

View File

@ -19,7 +19,11 @@ export default class ChatMessageVisibilityObserver extends Service {
entries.forEach((entry) => {
entry.target.dataset.visible = entry.isIntersecting;
if (entry.isIntersecting && !isTesting()) {
if (
!entry.target.dataset.stagedId &&
entry.isIntersecting &&
!isTesting()
) {
this.chat.updateLastReadMessage();
}
});

View File

@ -86,6 +86,10 @@ export default class ChatStateManager extends Service {
return this.router.currentRouteName?.startsWith("chat");
}
get isActive() {
return this.isFullPageActive || this.isDrawerActive;
}
storeAppURL(URL = null) {
this._appURL = URL || this.router.currentURL;
}

View File

@ -0,0 +1,265 @@
import Service, { inject as service } from "@ember/service";
import { bind } from "discourse-common/utils/decorators";
import { CHANNEL_STATUSES } from "discourse/plugins/chat/discourse/models/chat-channel";
export default class ChatSubscriptionsManager extends Service {
@service store;
@service chatChannelsManager;
@service currentUser;
@service appEvents;
_channelSubscriptions = new Set();
startChannelSubscription(channel) {
if (
channel.currentUserMembership.muted ||
this._channelSubscriptions.has(channel.id)
) {
return;
}
this._channelSubscriptions.add(channel.id);
if (!channel.isDirectMessageChannel) {
this._startChannelMentionsSubscription(channel);
}
this._startChannelNewMessagesSubscription(channel);
}
stopChannelSubscription(channel) {
this.messageBus.unsubscribe(
`/chat/${channel.id}/new-messages`,
this._onNewMessages
);
if (!channel.isDirectMessageChannel) {
this.messageBus.unsubscribe(
`/chat/${channel.id}/new-mentions`,
this._onNewMentions
);
}
this._channelSubscriptions.delete(channel.id);
}
startChannelsSubscriptions(messageBusIds) {
this._startNewChannelSubscription(messageBusIds.new_channel);
this._startUserTrackingStateSubscription(messageBusIds.user_tracking_state);
this._startChannelsEditsSubscription(messageBusIds.channel_edits);
this._startChannelsStatusChangesSubscription(messageBusIds.channel_status);
this._startChannelsMetadataChangesSubscription(
messageBusIds.channel_metadata
);
}
stopChannelsSubscriptions() {
this._stopNewChannelSubscription();
this._stopUserTrackingStateSubscription();
this._stopChannelsEditsSubscription();
this._stopChannelsStatusChangesSubscription();
this._stopChannelsMetadataChangesSubscription();
(this.chatChannelsManager.channels || []).forEach((channel) => {
this.stopChannelSubscription(channel);
});
}
_startChannelMentionsSubscription(channel) {
this.messageBus.subscribe(
`/chat/${channel.id}/new-mentions`,
this._onNewMentions,
channel.meta.message_bus_last_ids.new_mentions
);
}
@bind
_onNewMentions(busData) {
this.chatChannelsManager.find(busData.channel_id).then((channel) => {
const membership = channel.currentUserMembership;
if (membership) {
membership.unread_mentions = (membership.unread_mentions || 0) + 1;
}
});
}
_startChannelNewMessagesSubscription(channel) {
this.messageBus.subscribe(
`/chat/${channel.id}/new-messages`,
this._onNewMessages,
channel.meta.message_bus_last_ids.new_messages
);
}
@bind
_onNewMessages(busData) {
this.chatChannelsManager.find(busData.channel_id).then((channel) => {
if (busData.user_id === this.currentUser.id) {
// User sent message, update tracking state to no unread
channel.currentUserMembership.chat_message_id = busData.message_id;
} else {
// Ignored user sent message, update tracking state to no unread
if (this.currentUser.ignored_users.includes(busData.username)) {
channel.currentUserMembership.chat_message_id = busData.message_id;
} else {
// Message from other user. Increment trackings state
if (
busData.message_id >
(channel.currentUserMembership.chat_message_id || 0)
) {
channel.currentUserMembership.unread_count =
channel.currentUserMembership.unread_count + 1;
}
}
}
channel.set("last_message_sent_at", new Date());
});
}
_startUserTrackingStateSubscription(lastId) {
if (!this.currentUser) {
return;
}
this.messageBus.subscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingStateUpdate,
lastId
);
}
_stopUserTrackingStateSubscription() {
if (!this.currentUser) {
return;
}
this.messageBus.unsubscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingStateUpdate
);
}
@bind
_onUserTrackingStateUpdate(data) {
this.chatChannelsManager.find(data.chat_channel_id).then((channel) => {
if (
channel?.currentUserMembership?.chat_message_id < data.chat_message_id
) {
channel.currentUserMembership.chat_message_id = data.chat_message_id;
channel.currentUserMembership.unread_count = 0;
channel.currentUserMembership.unread_mentions = 0;
}
});
}
_startNewChannelSubscription(lastId) {
this.messageBus.subscribe(
"/chat/new-channel",
this._onNewChannelSubscription,
lastId
);
}
_stopNewChannelSubscription() {
this.messageBus.unsubscribe(
"/chat/new-channel",
this._onNewChannelSubscription
);
}
@bind
_onNewChannelSubscription(data) {
this.chatChannelsManager.find(data.channel.id).then((channel) => {
// we need to refrehs here to have correct last message ids
channel.meta = data.channel.meta;
if (
channel.isDirectMessageChannel &&
!channel.currentUserMembership.following
) {
channel.currentUserMembership.unread_count = 1;
}
this.chatChannelsManager.follow(channel);
});
}
_startChannelsMetadataChangesSubscription(lastId) {
this.messageBus.subscribe(
"/chat/channel-metadata",
this._onChannelMetadata,
lastId
);
}
_startChannelsEditsSubscription(lastId) {
this.messageBus.subscribe(
"/chat/channel-edits",
this._onChannelEdits,
lastId
);
}
_startChannelsStatusChangesSubscription(lastId) {
this.messageBus.subscribe(
"/chat/channel-status",
this._onChannelStatus,
lastId
);
}
_stopChannelsStatusChangesSubscription() {
this.messageBus.unsubscribe("/chat/channel-status", this._onChannelStatus);
}
_stopChannelsEditsSubscription() {
this.messageBus.unsubscribe("/chat/channel-edits", this._onChannelEdits);
}
_stopChannelsMetadataChangesSubscription() {
this.messageBus.unsubscribe(
"/chat/channel-metadata",
this._onChannelMetadata
);
}
@bind
_onChannelMetadata(busData) {
this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
memberships_count: busData.memberships_count,
});
this.appEvents.trigger("chat:refresh-channel-members");
}
});
}
@bind
_onChannelEdits(busData) {
this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
title: busData.name,
description: busData.description,
});
}
});
}
@bind
_onChannelStatus(busData) {
this.chatChannelsManager.find(busData.chat_channel_id).then((channel) => {
channel.set("status", busData.status);
// it is not possible for the user to set their last read message id
// if the channel has been archived, because all the messages have
// been deleted. we don't want them seeing the blue dot anymore so
// just completely reset the unreads
if (busData.status === CHANNEL_STATUSES.archived) {
channel.currentUserMembership.unread_count = 0;
channel.currentUserMembership.unread_mentions = 0;
}
});
}
}

View File

@ -5,22 +5,15 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service";
import Site from "discourse/models/site";
import { ajax } from "discourse/lib/ajax";
import { A } from "@ember/array";
import { generateCookFunction } from "discourse/lib/text";
import { cancel, next } from "@ember/runloop";
import { and } from "@ember/object/computed";
import { computed } from "@ember/object";
import { Promise } from "rsvp";
import ChatChannel, {
CHANNEL_STATUSES,
CHATABLE_TYPES,
} from "discourse/plugins/chat/discourse/models/chat-channel";
import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
import discourseDebounce from "discourse-common/lib/debounce";
import EmberObject, { computed } from "@ember/object";
import ChatApi from "discourse/plugins/chat/discourse/lib/chat-api";
import discourseLater from "discourse-common/lib/later";
import userPresent from "discourse/lib/user-presence";
import { bind } from "discourse-common/utils/decorators";
export const LIST_VIEW = "list_view";
export const CHAT_VIEW = "chat_view";
@ -36,30 +29,21 @@ const READ_INTERVAL = 1000;
export default class Chat extends Service {
@service appEvents;
@service chatNotificationManager;
@service chatSubscriptionsManager;
@service chatStateManager;
@service presence;
@service router;
@service site;
@service chatChannelsManager;
activeChannel = null;
allChannels = null;
cook = null;
directMessageChannels = null;
hasFetchedChannels = false;
hasUnreadMessages = false;
idToTitleMap = null;
lastUserTrackingMessageId = null;
messageId = null;
presenceChannel = null;
publicChannels = null;
sidebarActive = false;
unreadUrgentCount = null;
directMessagesLimit = 20;
isNetworkUnreliable = false;
@and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat;
_fetchingChannels = null;
_onNewMentionsCallbacks = new Map();
_onNewMessagesCallbacks = new Map();
@computed("currentUser.staff", "currentUser.groups.[]")
get userCanDirectMessage() {
@ -81,7 +65,6 @@ export default class Chat extends Service {
super.init(...arguments);
if (this.userCanChat) {
this.set("allChannels", []);
this.presenceChannel = this.presence.getChannel("/chat/online");
this.draftStore = {};
@ -114,38 +97,24 @@ export default class Chat extends Service {
}
setupWithPreloadedChannels(channels) {
this.currentUser.set("chat_channel_tracking_state", {});
this._processChannels(channels || {});
this.subscribeToChannelMessageBus();
this.userChatChannelTrackingStateChanged();
this.appEvents.trigger("chat:refresh-channels");
}
this.chatSubscriptionsManager.startChannelsSubscriptions(
channels.meta.message_bus_last_ids
);
this.presenceChannel.subscribe(channels.global_presence_channel_state);
setupWithoutPreloadedChannels() {
this.getChannels().then(() => {
this.subscribeToChannelMessageBus();
});
}
subscribeToChannelMessageBus() {
this._subscribeToNewChannelUpdates();
this._subscribeToUserTrackingChannel();
this._subscribeToChannelEdits();
this._subscribeToChannelMetadata();
this._subscribeToChannelStatusChange();
[...channels.public_channels, ...channels.direct_message_channels].forEach(
(channelObject) => {
const channel = this.chatChannelsManager.store(channelObject);
return this.chatChannelsManager.follow(channel);
}
);
}
willDestroy() {
super.willDestroy(...arguments);
if (this.userCanChat) {
this.set("allChannels", null);
this._unsubscribeFromNewDmChannelUpdates();
this._unsubscribeFromUserTrackingChannel();
this._unsubscribeFromChannelEdits();
this._unsubscribeFromChannelMetadata();
this._unsubscribeFromChannelStatusChange();
this._unsubscribeFromAllChatChannels();
this.chatSubscriptionsManager.stopChannelsSubscriptions();
}
}
@ -186,10 +155,7 @@ export default class Chat extends Service {
return;
}
if (
this.chatStateManager.isFullPageActive ||
this.chatStateManager.isDrawerActive
) {
if (this.chatStateManager.isActive) {
this.presenceChannel.enter({ activeOptions: CHAT_ONLINE_OPTIONS });
} else {
this.presenceChannel.leave();
@ -199,61 +165,10 @@ export default class Chat extends Service {
getDocumentTitleCount() {
return this.chatNotificationManager.shouldCountChatInDocTitle()
? this.unreadUrgentCount
? this.chatChannelsManager.unreadUrgentCount
: 0;
}
_channelObject() {
return {
publicChannels: this.publicChannels,
directMessageChannels: this.directMessageChannels,
};
}
truncateDirectMessageChannels(channels) {
return channels.slice(0, this.directMessagesLimit);
}
async getChannelsWithFilter(filter, opts = { excludeActiveChannel: true }) {
let sortedChannels = this.allChannels.sort((a, b) => {
return new Date(a.last_message_sent_at) > new Date(b.last_message_sent_at)
? -1
: 1;
});
const trimmedFilter = filter.trim();
const lowerCasedFilter = filter.toLowerCase();
const { activeChannel } = this;
return sortedChannels.filter((channel) => {
if (
opts.excludeActiveChannel &&
activeChannel &&
activeChannel.id === channel.id
) {
return false;
}
if (!trimmedFilter.length) {
return true;
}
if (channel.isDirectMessageChannel) {
let userFound = false;
channel.chatable.users.forEach((user) => {
if (
user.username.toLowerCase().includes(lowerCasedFilter) ||
user.name?.toLowerCase().includes(lowerCasedFilter)
) {
return (userFound = true);
}
});
return userFound;
} else {
return channel.title.toLowerCase().includes(lowerCasedFilter);
}
});
}
switchChannelUpOrDown(direction) {
const { activeChannel } = this;
if (!activeChannel) {
@ -262,15 +177,11 @@ export default class Chat extends Service {
let currentList, otherList;
if (activeChannel.isDirectMessageChannel) {
currentList = this.truncateDirectMessageChannels(
this.directMessageChannels
);
otherList = this.publicChannels;
currentList = this.chatChannelsManager.truncatedDirectMessageChannels;
otherList = this.chatChannelsManager.publicMessageChannels;
} else {
currentList = this.publicChannels;
otherList = this.truncateDirectMessageChannels(
this.directMessageChannels
);
currentList = this.chatChannelsManager.publicMessageChannels;
otherList = this.chatChannelsManager.truncatedDirectMessageChannels;
}
const directionUp = direction === "up";
@ -296,109 +207,6 @@ export default class Chat extends Service {
}
}
getChannels() {
return new Promise((resolve) => {
if (this.hasFetchedChannels) {
return resolve(this._channelObject());
}
if (!this._fetchingChannels) {
this._fetchingChannels = this._refreshChannels();
}
this._fetchingChannels
.then(() => resolve(this._channelObject()))
.finally(() => (this._fetchingChannels = null));
});
}
forceRefreshChannels() {
this.set("hasFetchedChannels", false);
this._unsubscribeFromAllChatChannels();
return this.getChannels();
}
refreshTrackingState() {
if (!this.currentUser) {
return;
}
return ajax("/chat/chat_channels.json")
.then((response) => {
this.currentUser.set("chat_channel_tracking_state", {});
(response.direct_message_channels || []).forEach((channel) => {
this._updateUserTrackingState(channel);
});
(response.public_channels || []).forEach((channel) => {
this._updateUserTrackingState(channel);
});
})
.finally(() => {
this.userChatChannelTrackingStateChanged();
});
}
_refreshChannels() {
return new Promise((resolve) => {
this.setProperties({
loading: true,
allChannels: [],
});
this.currentUser.set("chat_channel_tracking_state", {});
ajax("/chat/chat_channels.json").then((channels) => {
this._processChannels(channels);
this.userChatChannelTrackingStateChanged();
this.appEvents.trigger("chat:refresh-channels");
resolve(this._channelObject());
});
});
}
_processChannels(channels) {
// Must be set first because `processChannels` relies on this data.
this.set("messageBusLastIds", channels.message_bus_last_ids);
this.setProperties({
publicChannels: A(
this.sortPublicChannels(
(channels.public_channels || []).map((channel) =>
this.processChannel(channel)
)
)
),
directMessageChannels: A(
this.sortDirectMessageChannels(
(channels.direct_message_channels || []).map((channel) =>
this.processChannel(channel)
)
)
),
hasFetchedChannels: true,
loading: false,
});
const idToTitleMap = {};
this.allChannels.forEach((c) => {
idToTitleMap[c.id] = c.title;
});
this.set("idToTitleMap", idToTitleMap);
this.presenceChannel.subscribe(channels.global_presence_channel_state);
}
reSortDirectMessageChannels() {
this.set(
"directMessageChannels",
this.sortDirectMessageChannels(this.directMessageChannels)
);
}
async getChannelBy(key, value) {
return this.getChannels().then(() => {
if (!isNaN(value)) {
value = parseInt(value, 10);
}
return (this.allChannels || []).findBy(key, value);
});
}
searchPossibleDirectMessageUsers(options) {
// TODO: implement a chat specific user search function
return userSearch(options);
@ -414,99 +222,54 @@ export default class Chat extends Service {
// if that is present and in the list of channels the user can access.
// If none of these options exist, then we get the first public channel,
// or failing that the first DM channel.
return this.getChannels().then(() => {
// Defined in order of significance.
let publicChannelWithMention,
dmChannelWithUnread,
publicChannelWithUnread,
publicChannel,
dmChannel,
defaultChannel;
// Defined in order of significance.
let publicChannelWithMention,
dmChannelWithUnread,
publicChannelWithUnread,
publicChannel,
dmChannel,
defaultChannel;
for (const [channel, state] of Object.entries(
this.currentUser.chat_channel_tracking_state
)) {
if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) {
if (!dmChannelWithUnread && state.unread_count > 0) {
dmChannelWithUnread = channel;
} else if (!dmChannel) {
dmChannel = channel;
}
} else {
if (state.unread_mentions > 0) {
publicChannelWithMention = channel;
break; // <- We have a public channel with a mention. Break and return this.
} else if (!publicChannelWithUnread && state.unread_count > 0) {
publicChannelWithUnread = channel;
} else if (
!defaultChannel &&
parseInt(this.siteSettings.chat_default_channel_id || 0, 10) ===
parseInt(channel, 10)
) {
defaultChannel = channel;
} else if (!publicChannel) {
publicChannel = channel;
}
this.chatChannelsManager.channels.forEach((channel) => {
const membership = channel.currentUserMembership;
if (channel.isDirectMessageChannel) {
if (!dmChannelWithUnread && membership.unread_count > 0) {
dmChannelWithUnread = channel.id;
} else if (!dmChannel) {
dmChannel = channel.id;
}
} else {
if (membership.unread_mentions > 0) {
publicChannelWithMention = channel.id;
return; // <- We have a public channel with a mention. Break and return this.
} else if (!publicChannelWithUnread && membership.unread_count > 0) {
publicChannelWithUnread = channel.id;
} else if (
!defaultChannel &&
parseInt(this.siteSettings.chat_default_channel_id || 0, 10) ===
channel.id
) {
defaultChannel = channel.id;
} else if (!publicChannel) {
publicChannel = channel.id;
}
}
return (
publicChannelWithMention ||
dmChannelWithUnread ||
publicChannelWithUnread ||
defaultChannel ||
publicChannel ||
dmChannel
);
});
}
sortPublicChannels(channels) {
return channels.sort((a, b) => a.title.localeCompare(b.title));
}
sortDirectMessageChannels(channels) {
return channels.sort((a, b) => {
const unreadCountA =
this.currentUser.chat_channel_tracking_state[a.id]?.unread_count || 0;
const unreadCountB =
this.currentUser.chat_channel_tracking_state[b.id]?.unread_count || 0;
if (unreadCountA === unreadCountB) {
return new Date(a.last_message_sent_at) >
new Date(b.last_message_sent_at)
? -1
: 1;
} else {
return unreadCountA > unreadCountB ? -1 : 1;
}
});
}
getIdealFirstChannelIdAndTitle() {
return this.getIdealFirstChannelId().then((channelId) => {
if (!channelId) {
return;
}
return {
id: channelId,
title: this.idToTitleMap[channelId],
};
});
return (
publicChannelWithMention ||
dmChannelWithUnread ||
publicChannelWithUnread ||
defaultChannel ||
publicChannel ||
dmChannel
);
}
async openChannelAtMessage(channelId, messageId = null) {
let channel = await this.getChannelBy("id", channelId);
if (channel) {
return this.chatChannelsManager.find(channelId).then((channel) => {
return this._openFoundChannelAtMessage(channel, messageId);
}
return ajax(`/chat/chat_channels/${channelId}`).then((response) => {
const queryParams = messageId ? { messageId } : {};
return this.router.transitionTo(
"chat.channel",
response.id,
slugifyChannel(response),
{ queryParams }
);
});
}
@ -559,380 +322,18 @@ export default class Chat extends Service {
this.appEvents.trigger("chat-live-pane:highlight-message", messageId);
}
async startTrackingChannel(channel) {
if (!channel.current_user_membership.following) {
return;
}
let existingChannel = await this.getChannelBy("id", channel.id);
if (existingChannel) {
return existingChannel; // User is already tracking this channel. return!
}
const existingChannels = channel.isDirectMessageChannel
? this.directMessageChannels
: this.publicChannels;
// this check shouldn't be needed given the previous check to existingChannel
// this is a safety net, to ensure we never track duplicated channels
existingChannel = existingChannels.findBy("id", channel.id);
if (existingChannel) {
return existingChannel;
}
const newChannel = this.processChannel(channel);
existingChannels.pushObject(newChannel);
this.currentUser.chat_channel_tracking_state[channel.id] =
EmberObject.create({
unread_count: 1,
unread_mentions: 0,
chatable_type: channel.chatable_type,
});
this.userChatChannelTrackingStateChanged();
if (channel.isDirectMessageChannel) {
this.reSortDirectMessageChannels();
}
if (channel.isPublicChannel) {
this.set("publicChannels", this.sortPublicChannels(this.publicChannels));
}
this.appEvents.trigger("chat:refresh-channels");
return newChannel;
}
async stopTrackingChannel(channel) {
return this.getChannelBy("id", channel.id).then((existingChannel) => {
if (existingChannel) {
return this.forceRefreshChannels();
}
});
}
_subscribeToChannelMetadata() {
this.messageBus.subscribe(
"/chat/channel-metadata",
this._onChannelMetadata,
this.messageBusLastIds.channel_metadata
);
}
_subscribeToChannelEdits() {
this.messageBus.subscribe(
"/chat/channel-edits",
this._onChannelEdits,
this.messageBusLastIds.channel_edits
);
}
_subscribeToChannelStatusChange() {
this.messageBus.subscribe("/chat/channel-status", this._onChannelStatus);
}
_unsubscribeFromChannelStatusChange() {
this.messageBus.unsubscribe("/chat/channel-status", this._onChannelStatus);
}
_unsubscribeFromChannelEdits() {
this.messageBus.unsubscribe("/chat/channel-edits", this._onChannelEdits);
}
_unsubscribeFromChannelMetadata() {
this.messageBus.unsubscribe(
"/chat/channel-metadata",
this._onChannelMetadata
);
}
_subscribeToNewChannelUpdates() {
this.messageBus.subscribe(
"/chat/new-channel",
this._onNewChannel,
this.messageBusLastIds.new_channel
);
}
_unsubscribeFromNewDmChannelUpdates() {
this.messageBus.unsubscribe("/chat/new-channel", this._onNewChannel);
}
_subscribeToSingleUpdateChannel(channel) {
if (channel.current_user_membership.muted) {
return;
}
// We do this first so we don't multi-subscribe to mention + messages
// messageBus channels for this chat channel, since _subscribeToSingleUpdateChannel
// is called from multiple places.
this._unsubscribeFromChatChannel(channel);
if (!channel.isDirectMessageChannel) {
this._subscribeToMentionChannel(channel);
}
this._subscribeToNewMessagesChannel(channel);
}
_subscribeToMentionChannel(channel) {
const onNewMentions = () => {
const trackingState =
this.currentUser.chat_channel_tracking_state[channel.id];
if (trackingState) {
const count = (trackingState.unread_mentions || 0) + 1;
trackingState.set("unread_mentions", count);
this.userChatChannelTrackingStateChanged();
}
};
this._onNewMentionsCallbacks.set(channel.id, onNewMentions);
this.messageBus.subscribe(
`/chat/${channel.id}/new-mentions`,
onNewMentions,
channel.message_bus_last_ids.new_mentions
);
}
_subscribeToNewMessagesChannel(channel) {
const onNewMessages = (busData) => {
const trackingState =
this.currentUser.chat_channel_tracking_state[channel.id];
if (busData.user_id === this.currentUser.id) {
// User sent message, update tracking state to no unread
trackingState.set("chat_message_id", busData.message_id);
} else {
// Ignored user sent message, update tracking state to no unread
if (this.currentUser.ignored_users.includes(busData.username)) {
trackingState.set("chat_message_id", busData.message_id);
} else {
// Message from other user. Increment trackings state
if (busData.message_id > (trackingState.chat_message_id || 0)) {
trackingState.set("unread_count", trackingState.unread_count + 1);
}
}
}
this.userChatChannelTrackingStateChanged();
channel.set("last_message_sent_at", new Date());
const directMessageChannel = (this.directMessageChannels || []).findBy(
"id",
parseInt(channel.id, 10)
);
if (directMessageChannel) {
this.reSortDirectMessageChannels();
}
};
this._onNewMessagesCallbacks.set(channel.id, onNewMessages);
this.messageBus.subscribe(
`/chat/${channel.id}/new-messages`,
onNewMessages,
channel.message_bus_last_ids.new_messages
);
}
@bind
_onChannelMetadata(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
memberships_count: busData.memberships_count,
});
this.appEvents.trigger("chat:refresh-channel-members");
}
});
}
@bind
_onChannelEdits(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (channel) {
channel.setProperties({
title: busData.name,
description: busData.description,
});
}
});
}
@bind
_onChannelStatus(busData) {
this.getChannelBy("id", busData.chat_channel_id).then((channel) => {
if (!channel) {
return;
}
channel.set("status", busData.status);
// it is not possible for the user to set their last read message id
// if the channel has been archived, because all the messages have
// been deleted. we don't want them seeing the blue dot anymore so
// just completely reset the unreads
if (busData.status === CHANNEL_STATUSES.archived) {
this.currentUser.chat_channel_tracking_state[channel.id] = {
unread_count: 0,
unread_mentions: 0,
chatable_type: channel.chatable_type,
};
this.userChatChannelTrackingStateChanged();
}
this.appEvents.trigger("chat:refresh-channel", channel.id);
}, this.messageBusLastIds.channel_status);
}
@bind
_onNewChannel(busData) {
this.startTrackingChannel(ChatChannel.create(busData.chat_channel));
}
async followChannel(channel) {
return ChatApi.followChatChannel(channel).then(() => {
this.startTrackingChannel(channel);
this._subscribeToSingleUpdateChannel(channel);
});
return this.chatChannelsManager.follow(channel);
}
async unfollowChannel(channel) {
return ChatApi.unfollowChatChannel(channel).then(() => {
this._unsubscribeFromChatChannel(channel);
this.stopTrackingChannel(channel);
return this.chatChannelsManager.unfollow(channel).then(() => {
if (channel === this.activeChannel && channel.isDirectMessageChannel) {
this.router.transitionTo("chat");
}
});
}
_unsubscribeFromAllChatChannels() {
(this.allChannels || []).forEach((channel) => {
this._unsubscribeFromChatChannel(channel);
});
}
_unsubscribeFromChatChannel(channel) {
this.messageBus.unsubscribe("/chat/*", this._onNewMessagesCallbacks);
if (!channel.isDirectMessageChannel) {
this.messageBus.unsubscribe("/chat/*", this._onNewMentionsCallbacks);
}
}
_subscribeToUserTrackingChannel() {
this.messageBus.subscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingState,
this.messageBusLastIds.user_tracking_state
);
}
_unsubscribeFromUserTrackingChannel() {
this.messageBus.unsubscribe(
`/chat/user-tracking-state/${this.currentUser.id}`,
this._onUserTrackingState
);
}
@bind
_onUserTrackingState(busData, _, messageId) {
const lastId = this.lastUserTrackingMessageId;
// we don't want this state to go backwards, only catch
// up if messages from messagebus were missed
if (!lastId || messageId > lastId) {
this.lastUserTrackingMessageId = messageId;
}
// we are too far out of sync, we should resync everything.
// this will trigger a route transition and blur the chat input
if (lastId && messageId > lastId + 1) {
return this.forceRefreshChannels();
}
const trackingState =
this.currentUser.chat_channel_tracking_state[busData.chat_channel_id];
if (trackingState) {
trackingState.set("chat_message_id", busData.chat_message_id);
trackingState.set("unread_count", 0);
trackingState.set("unread_mentions", 0);
this.userChatChannelTrackingStateChanged();
}
}
resetTrackingStateForChannel(channelId) {
const trackingState =
this.currentUser.chat_channel_tracking_state[channelId];
if (trackingState) {
trackingState.set("unread_count", 0);
this.userChatChannelTrackingStateChanged();
}
}
userChatChannelTrackingStateChanged() {
this._recalculateUnreadMessages();
this.appEvents.trigger("chat:user-tracking-state-changed");
}
_recalculateUnreadMessages() {
let unreadPublicCount = 0;
let unreadUrgentCount = 0;
let headerNeedsRerender = false;
Object.values(this.currentUser.chat_channel_tracking_state).forEach(
(state) => {
if (state.muted) {
return;
}
if (state.chatable_type === CHATABLE_TYPES.directMessageChannel) {
unreadUrgentCount += state.unread_count || 0;
} else {
unreadUrgentCount += state.unread_mentions || 0;
unreadPublicCount += state.unread_count || 0;
}
}
);
let hasUnreadPublic = unreadPublicCount > 0;
if (hasUnreadPublic !== this.hasUnreadMessages) {
headerNeedsRerender = true;
this.set("hasUnreadMessages", hasUnreadPublic);
}
if (unreadUrgentCount !== this.unreadUrgentCount) {
headerNeedsRerender = true;
this.set("unreadUrgentCount", unreadUrgentCount);
}
this.currentUser.notifyPropertyChange("chat_channel_tracking_state");
if (headerNeedsRerender) {
this.appEvents.trigger("chat:rerender-header");
this.appEvents.trigger("notifications:changed");
}
}
processChannel(channel) {
channel = ChatChannel.create(channel);
this._subscribeToSingleUpdateChannel(channel);
this._updateUserTrackingState(channel);
this.allChannels.push(channel);
return channel;
}
_updateUserTrackingState(channel) {
this.currentUser.chat_channel_tracking_state[channel.id] =
EmberObject.create({
chatable_type: channel.chatable_type,
muted: channel.current_user_membership.muted,
unread_count: channel.current_user_membership.unread_count,
unread_mentions: channel.current_user_membership.unread_mentions,
chat_message_id: channel.current_user_membership.last_read_message_id,
});
}
upsertDmChannelForUser(channel, user) {
const usernames = (channel.chatable.users || [])
.mapBy("username")
@ -951,9 +352,9 @@ export default class Chat extends Service {
data: { usernames: usernames.uniq() },
})
.then((response) => {
const chatChannel = ChatChannel.create(response.chat_channel);
this.startTrackingChannel(chatChannel);
return chatChannel;
const channel = this.chatChannelsManager.store(response.channel);
this.chatChannelsManager.follow(channel);
return channel;
})
.catch(popupAjaxError);
}
@ -1031,17 +432,8 @@ export default class Chat extends Service {
10
);
const hasUnreadMessages = latestUnreadMsgId > channel.lastSendReadMessageId;
if (
!hasUnreadMessages &&
this.currentUser.chat_channel_tracking_state[this.activeChannel.id]
?.unread_count > 0
) {
// Weird state here where the chat_channel_tracking_state is wrong. Need to reset it.
this.resetTrackingStateForChannel(this.activeChannel.id);
}
const hasUnreadMessages =
latestUnreadMsgId > channel.currentUserMembership.last_read_message_id;
if (hasUnreadMessages) {
channel.updateLastReadMessage(latestUnreadMsgId);
}

View File

@ -1 +1 @@
<FullPageChat @refreshModel={{route-action "refreshModel"}} />
<FullPageChat />

View File

@ -1 +1,5 @@
<ChatChannelAboutView @channel={{this.model.chatChannel}} @onEditChatChannelTitle={{action "onEditChatChannelTitle"}} @onEditChatChannelDescription={{action "onEditChatChannelDescription"}} />
<ChatChannelAboutView
@channel={{this.model}}
@onEditChatChannelTitle={{action "onEditChatChannelTitle"}}
@onEditChatChannelDescription={{action "onEditChatChannelDescription"}}
/>

View File

@ -1 +1 @@
<ChatChannelMembersView @channel={{this.model.chatChannel}} />
<ChatChannelMembersView @channel={{this.model}} />

View File

@ -1 +1 @@
<ChatChannelSettingsView @channel={{this.model.chatChannel}} />
<ChatChannelSettingsView @channel={{this.model}} />

View File

@ -3,16 +3,17 @@
<div class="chat-channel-header-details">
<div class="chat-full-page-header__left-actions">
{{#if this.chatChannelInfoRouteOriginManager.isBrowse}}
<LinkTo @route="chat.browse" class="chat-full-page-header__back-btn no-text btn-flat btn" title={{i18n "chat.channel_info.back_to_all_channel"}}>
<LinkTo
@route="chat.browse"
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.channel_info.back_to_all_channel"}}
>
{{d-icon "chevron-left"}}
</LinkTo>
{{else}}
<LinkTo
@route="chat.channel"
@models={{array
this.model.chatChannel.id
(slugify-channel this.model.chatChannel)
}}
@models={{array this.model.id (slugify-channel this.model)}}
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.channel_info.back_to_channel"}}
>
@ -21,7 +22,7 @@
{{/if}}
</div>
<ChatChannelTitle @channel={{this.model.chatChannel}} />
<ChatChannelTitle @channel={{this.model}} />
</div>
</div>
@ -35,16 +36,13 @@
>
<LinkTo
@route={{concat "chat.channel.info." tab}}
@models={{array
this.model.chatChannel.id
(slugify-channel this.model.chatChannel)
}}
@models={{array this.model.id (slugify-channel this.model)}}
class="chat-tabs-list__link"
>
<span>{{i18n (concat "chat.channel_info.tabs." tab)}}</span>
{{#if (eq tab "members")}}
<span class="chat-tabs__memberships-count">
({{this.model.chatChannel.membershipsCount}})
({{this.model.membershipsCount}})
</span>
{{/if}}
</LinkTo>

View File

@ -17,15 +17,15 @@
</label>
{{/if}}
<label class="create-channel-label">
<label for="channel-name" class="create-channel-label">
{{i18n "chat.create_channel.name"}}
</label>
<Input class="create-channel-name-input" @type="text" @value={{this.name}} />
<Input name="channel-name" class="create-channel-name-input" @type="text" @value={{this.name}} />
<label class="create-channel-label">
<label for="channel-description" class="create-channel-label">
{{i18n "chat.create_channel.description"}}
</label>
<Input class="create-channel-description-input" @type="textarea" @value={{this.description}} />
<Input name="channel-description" class="create-channel-description-input" @type="textarea" @value={{this.description}} />
</DModalBody>
<div class="modal-footer">

View File

@ -1,87 +1,24 @@
import getURL from "discourse-common/lib/get-url";
import { createWidget } from "discourse/widgets/widget";
import { h } from "virtual-dom";
import { iconNode } from "discourse-common/lib/icon-library";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
export default createWidget("header-chat-link", {
buildKey: () => "header-chat-link",
chat: null,
tagName: "li.header-dropdown-toggle.open-chat",
title: "chat.title",
services: ["chat", "router", "chatStateManager"],
export default createWidget("chat-header-icon", {
tagName: "li.header-dropdown-toggle.chat-header-icon",
title: "chat.title_capitalized",
services: ["chat"],
html() {
if (!this.chat.userCanChat) {
return;
}
if (this.currentUser.isInDoNotDisturb()) {
return this.chatLinkHtml();
}
let indicator;
if (this.chat.unreadUrgentCount) {
indicator = h(
"div.chat-channel-unread-indicator.urgent",
{},
h(
"div.number-wrap",
{},
h("div.number", {}, this.chat.unreadUrgentCount)
)
);
} else if (this.chat.hasUnreadMessages) {
indicator = h("div.chat-channel-unread-indicator");
}
return this.chatLinkHtml(indicator);
},
chatLinkHtml(indicatorNode) {
return h(
`a.icon${
this.chatStateManager.isFullPageActive ||
this.chatStateManager.isDrawerActive
? ".active"
: ""
}`,
{ attributes: { tabindex: 0 } },
[iconNode("comment"), indicatorNode].filter(Boolean)
);
},
mouseUp(e) {
if (e.which === 2) {
// Middle mouse click
window.open(getURL("/chat"), "_blank").focus();
}
},
keyUp(e) {
if (e.code === "Enter") {
return this.click();
}
},
click() {
if (this.chatStateManager.isFullPageActive && this.site.desktopView) {
return;
}
if (this.chatStateManager.isFullPageActive && this.site.mobileView) {
return this.router.transitionTo("chat");
}
if (this.chatStateManager.isDrawerActive) {
return this.router.transitionTo("chat");
} else {
return this.router.transitionTo(
this.chatStateManager.lastKnownChatURL || "chat"
);
}
},
chatRerenderHeader() {
this.scheduleRerender();
return [
new RenderGlimmer(
this,
"div.widget-component-connector",
hbs`<ChatHeaderIcon />`
),
];
},
});

View File

@ -103,8 +103,30 @@ $float-height: 530px;
}
}
.header-dropdown-toggle.open-chat {
.header-dropdown-toggle.chat-header-icon {
.icon {
.chat-channel-unread-indicator {
border: 2px solid var(--header_background);
position: absolute;
right: 2px;
bottom: 2px;
transition: border-color linear 0.15s;
}
}
span.icon {
cursor: auto;
&:hover {
.d-icon {
color: var(--header_primary-low-mid);
}
background: none;
}
}
a.icon {
&.active {
.d-icon-comment {
color: var(--primary-medium);
@ -116,14 +138,6 @@ $float-height: 530px;
border-color: var(--primary-low);
}
}
.chat-channel-unread-indicator {
border: 2px solid var(--header_background);
position: absolute;
right: 2px;
bottom: 2px;
transition: border-color linear 0.15s;
}
}
}

View File

@ -114,7 +114,7 @@ html.has-full-page-chat body {
margin-left: 0;
}
.header-dropdown-toggle.open-chat {
.header-dropdown-toggle.chat-header-icon {
.icon {
&.active {
border: 1px solid var(--primary-low);

View File

@ -15,7 +15,7 @@
}
}
}
.header-dropdown-toggle.open-chat {
.header-dropdown-toggle.chat-header-icon {
.chat-channel-unread-indicator {
border-color: var(--primary-very-low);
}

View File

@ -116,10 +116,10 @@ en:
without_membership:
one: "%{username} has not joined this channel."
other: "%{username} and %{others} have not joined this channel."
group_mentions_disabled:
group_mentions_disabled:
one: "%{group_name} doesn't allow mentions"
other: "%{group_name} and %{others} doesn't allow mentions"
too_many_members:
too_many_members:
one: "%{group_name} has too many members. No one was notified"
other: "%{group_name} and %{others} have too many members. No one was notified"
warning_multiple:

View File

@ -45,6 +45,7 @@ class Chat::ChatChannelArchiveService
:chat_channel_archive,
chat_channel_archive_id: chat_channel.chat_channel_archive.id,
)
chat_channel.chat_channel_archive
end
attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title

View File

@ -19,14 +19,15 @@ class Chat::ChatMessageReactor
message = ensure_chat_message!(message_id)
validate_max_reactions!(message, react_action, emoji)
reaction = nil
ActiveRecord::Base.transaction do
enforce_channel_membership!
create_reaction(message, react_action, emoji)
reaction = create_reaction(message, react_action, emoji)
end
publish_reaction(message, react_action, emoji)
message
reaction
end
private

View File

@ -120,7 +120,6 @@ after_initialize do
)
load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__)
load File.expand_path("../app/controllers/chat_controller.rb", __FILE__)
load File.expand_path("../app/controllers/chat_channels_controller.rb", __FILE__)
load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__)
load File.expand_path("../app/controllers/direct_messages_controller.rb", __FILE__)
load File.expand_path("../app/controllers/incoming_chat_webhooks_controller.rb", __FILE__)
@ -207,13 +206,25 @@ after_initialize do
load File.expand_path("../app/services/chat_message_destroyer.rb", __FILE__)
load File.expand_path("../app/controllers/api_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_channel_memberships_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_current_user_channels_controller.rb", __FILE__)
load File.expand_path(
"../app/controllers/api/chat_channel_notifications_settings_controller.rb",
"../app/controllers/api/chat_channels_current_user_membership_controller.rb",
__FILE__,
)
load File.expand_path("../app/controllers/api/chat_channels_memberships_controller.rb", __FILE__)
load File.expand_path(
"../app/controllers/api/chat_channels_messages_moves_controller.rb",
__FILE__,
)
load File.expand_path("../app/controllers/api/chat_channels_archives_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_channels_status_controller.rb", __FILE__)
load File.expand_path(
"../app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb",
__FILE__,
)
load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__)
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
if Discourse.allow_dev_populate?
@ -245,7 +256,6 @@ after_initialize do
Category.prepend Chat::CategoryExtension
User.prepend Chat::UserExtension
Jobs::UserEmail.prepend Chat::UserEmailExtension
Bookmark.register_bookmarkable(ChatMessageBookmarkable)
end
@ -579,12 +589,23 @@ after_initialize do
end
Chat::Engine.routes.draw do
namespace :api do
get "/chat_channels" => "chat_channels#index"
get "/chat_channels/:chat_channel_id/memberships" => "chat_channel_memberships#index"
put "/chat_channels/:chat_channel_id" => "chat_channels#update"
put "/chat_channels/:chat_channel_id/notifications_settings" =>
"chat_channel_notifications_settings#update"
namespace :api, defaults: { format: :json } do
get "/chatables" => "chat_chatables#index"
get "/channels" => "chat_channels#index"
get "/channels/me" => "chat_current_user_channels#index"
post "/channels" => "chat_channels#create"
delete "/channels/:channel_id" => "chat_channels#destroy"
put "/channels/:channel_id" => "chat_channels#update"
get "/channels/:channel_id" => "chat_channels#show"
put "/channels/:channel_id/status" => "chat_channels_status#update"
post "/channels/:channel_id/messages/moves" => "chat_channels_messages_moves#create"
post "/channels/:channel_id/archives" => "chat_channels_archives#create"
get "/channels/:channel_id/memberships" => "chat_channels_memberships#index"
delete "/channels/:channel_id/memberships/me" =>
"chat_channels_current_user_membership#destroy"
post "/channels/:channel_id/memberships/me" => "chat_channels_current_user_membership#create"
put "/channels/:channel_id/notifications-settings/me" =>
"chat_channels_current_user_notifications_settings#update"
# Category chatables controller hints. Only used by staff members, we don't want to leak category permissions.
get "/category-chatables/:id/permissions" => "category_chatables#permissions",
@ -607,18 +628,9 @@ after_initialize do
# chat_channel_controller routes
get "/chat_channels" => "chat_channels#index"
put "/chat_channels" => "chat_channels#create"
get "/chat_channels/search" => "chat_channels#search"
post "/chat_channels/:chat_channel_id" => "chat_channels#edit"
post "/chat_channels/:chat_channel_id/notification_settings" =>
"chat_channels#notification_settings"
post "/chat_channels/:chat_channel_id/follow" => "chat_channels#follow"
post "/chat_channels/:chat_channel_id/unfollow" => "chat_channels#unfollow"
get "/chat_channels/:chat_channel_id" => "chat_channels#show"
put "/chat_channels/:chat_channel_id/archive" => "chat_channels#archive"
put "/chat_channels/:chat_channel_id/retry_archive" => "chat_channels#retry_archive"
put "/chat_channels/:chat_channel_id/change_status" => "chat_channels#change_status"
delete "/chat_channels/:chat_channel_id" => "chat_channels#destroy"
# chat_controller routes
get "/" => "chat#respond"
@ -645,7 +657,6 @@ after_initialize do
put "/:chat_channel_id/:message_id/rebake" => "chat#rebake"
post "/:chat_channel_id/:message_id/flag" => "chat#flag"
post "/:chat_channel_id/quote" => "chat#quote_messages"
put "/:chat_channel_id/move_messages_to_channel" => "chat#move_messages_to_channel"
put "/:chat_channel_id/restore/:message_id" => "chat#restore"
get "/lookup/:message_id" => "chat#lookup_message"
put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read"
@ -742,7 +753,7 @@ after_initialize do
}
register_user_destroyer_on_content_deletion_callback(
Proc.new { |user| Jobs.enqueue(:delete_user_messages, user_id: user.id) }
Proc.new { |user| Jobs.enqueue(:delete_user_messages, user_id: user.id) },
)
end

View File

@ -16,19 +16,37 @@ Fabricator(:chat_channel) do
end
chatable { Fabricate(:category) }
type do |attrs|
attrs[:chatable_type] == "Category" || attrs[:chatable]&.is_a?(Category) ? "CategoryChannel" : "DirectMessageChannel"
if attrs[:chatable_type] == "Category" || attrs[:chatable]&.is_a?(Category)
"CategoryChannel"
else
"DirectMessageChannel"
end
end
status { :open }
end
Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {}
Fabricator(:private_category_channel, from: :category_channel, class_name: :category_channel) do
transient :group
chatable { |attrs| Fabricate(:private_category, group: attrs[:group] || Group[:staff]) }
end
Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_message_channel) do
transient :users
transient :users, following: true, with_membership: true
chatable do |attrs|
Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)])
end
status { :open }
name nil
after_create do |channel, attrs|
if attrs[:with_membership]
channel.chatable.users.each do |user|
membership = channel.add(user)
membership.update!(following: false) if attrs[:following] == false
end
end
end
end
Fabricator(:chat_message) do
@ -37,6 +55,7 @@ Fabricator(:chat_message) do
message "Beep boop"
cooked { |attrs| ChatMessage.cook(attrs[:message]) }
cooked_version ChatMessage::BAKED_VERSION
in_reply_to nil
end
Fabricator(:chat_mention) do
@ -49,6 +68,9 @@ Fabricator(:chat_message_reaction) do
chat_message { Fabricate(:chat_message) }
user { Fabricate(:user) }
emoji { %w[+1 tada heart joffrey_facepalm].sample }
after_build do |chat_message_reaction|
chat_message_reaction.chat_message.chat_channel.add(chat_message_reaction.user)
end
end
Fabricator(:chat_upload) do

View File

@ -138,7 +138,7 @@ describe Jobs::AutoJoinChannelBatch do
end
expect(messages.size).to eq(1)
expect(messages.first.data.dig(:chat_channel, :id)).to eq(channel.id)
expect(messages.first.data.dig(:channel, :id)).to eq(channel.id)
end
describe "context when the channel's category is read restricted" do

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