WIP: Mark all chat channels read

This commit is contained in:
Martin Brennan 2023-03-10 09:30:27 +10:00 committed by Joffrey JAFFEUX
parent aeab38aff1
commit 6f15403f91
12 changed files with 636 additions and 19 deletions

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Chat::Api < Chat::ChatBaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::Api::ChatTrackingController < Chat::Api
def read
channel_id = params[:channel_id]
message_id = params[:message_id]
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Chat::ChatBaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end

View File

@ -0,0 +1,447 @@
# frozen_string_literal: true
class Chat::ChatController < Chat::ChatBaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChatChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, ChatChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChatChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::ChatMessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = ChatMessage.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::ChatMessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
ChatPublisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
ChatPublisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::ChatMessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
with_service(Chat::Service::UpdateUserLastRead, channel_id: params[:chat_channel_id]) do
on_failed_policy(:ensure_message_id_recency) do
raise Discourse::InvalidParameters.new(:message_id)
end
on_failed_policy(:ensure_message_exists) { raise Discourse::NotFound }
on_model_not_found(:active_membership) { raise Discourse::NotFound }
on_model_not_found(:channel) { raise Discourse::NotFound }
end
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the ChatView below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::ChatMessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
ChatMessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
ChatPublisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless ChatChannel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
ChatTranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
ChatDraft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
ChatMessage
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end

View File

@ -5,6 +5,9 @@ module Chat
@_result
end
# @param service [Class] A class including {Chat::Service::Base}
# @param dependencies [kwargs] Any additional params to load into the service context,
# in addition to controller @params.
def with_service(service, default_actions: true, **dependencies, &block)
object = self
merged_block =

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
module Chat
module Service
# Service responsible for updating the last read message id of a membership.
#
# @example
# Chat::Service::UpdateUserLastRead.call(channel_id: 2, message_id: 3, guardian: guardian)
#
class UpdateUserLastRead
include Base
# @!method call(user_id:, channel_id:, message_id:, guardian:)
# @param [Integer] channel_id
# @param [Integer] message_id
# @param [Guardian] guardian
# @return [Chat::Service::Base::Context]
contract
model :channel
model :active_membership
policy :invalid_access
policy :ensure_message_exists
policy :ensure_message_id_recency
step :update_last_read_message_id
step :mark_associated_mentions_as_read
step :publish_new_last_read_to_clients
# @!visibility private
class Contract
attribute :message_id, :integer
attribute :channel_id, :integer
validates :message_id, :channel_id, presence: true
end
private
def fetch_channel(contract:, **)
ChatChannel.find_by(id: contract.channel_id)
end
def fetch_active_membership(guardian:, channel:, **)
Chat::ChatChannelMembershipManager.new(channel).find_for_user(
guardian.user,
following: true,
)
end
def invalid_access(guardian:, active_membership:, **)
guardian.can_join_chat_channel?(active_membership.chat_channel)
end
def ensure_message_exists(channel:, contract:, **)
ChatMessage.with_deleted.exists?(chat_channel_id: channel.id, id: contract.message_id)
end
def ensure_message_id_recency(contract:, active_membership:, **)
!active_membership.last_read_message_id ||
contract.message_id >= active_membership.last_read_message_id
end
def update_last_read_message_id(contract:, active_membership:, **)
active_membership.update!(last_read_message_id: contract.message_id)
end
def mark_associated_mentions_as_read(active_membership:, contract:, **)
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: active_membership.user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", contract.message_id)
.where("chat_messages.chat_channel_id = ?", active_membership.chat_channel.id)
.update_all(read: true)
end
def publish_new_last_read_to_clients(guardian:, channel:, contract:, **)
ChatPublisher.publish_user_tracking_state(guardian.user, channel.id, contract.message_id)
end
end
end
end

View File

@ -17,6 +17,9 @@ export default {
const router = container.lookup("service:router");
const appEvents = container.lookup("service:app-events");
const chatStateManager = container.lookup("service:chat-state-manager");
const chatChannelsManager = container.lookup(
"service:chat-channels-manager"
);
const openChannelSelector = (e) => {
e.preventDefault();
e.stopPropagation();
@ -92,6 +95,12 @@ export default {
appEvents.trigger("chat:toggle-close", event);
};
const markAllChannelsRead = (event) => {
event.preventDefault();
event.stopPropagation();
chatChannelsManager.markAllChannelsRead();
};
withPluginApi("0.12.1", (api) => {
api.addKeyboardShortcut(`${KEY_MODIFIER}+k`, openChannelSelector, {
global: true,
@ -201,6 +210,21 @@ export default {
},
},
});
api.addKeyboardShortcut(
`shift+esc`,
(event) => markAllChannelsRead(event),
{
global: true,
help: {
category: "chat",
name: "chat.keyboard_shortcuts.mark_all_channels_read",
definition: {
keys1: ["shift", "esc"],
keysDelimiter: "plus",
},
},
}
);
});
},
};

View File

@ -282,6 +282,13 @@ export default class ChatApi extends Service {
);
}
updateCurrentUserTracking({ channelId, messageId } = {}) {
return this.#putRequest(`/tracking/read/me`, {
channel_id: channelId,
message_id: messageId,
});
}
get #basePath() {
return "/chat/api";
}

View File

@ -1,4 +1,5 @@
import Service, { inject as service } from "@ember/service";
import { debounce } from "discourse-common/utils/decorators";
import Promise from "rsvp";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { tracked } from "@glimmer/tracking";
@ -82,6 +83,16 @@ export default class ChatChannelsManager extends Service {
});
}
@debounce(300)
async markAllChannelsRead() {
return this.chatApi.updateCurrentUserTracking().then(() => {
this.channels.forEach((channel) => {
channel.currentUserMembership.unread_count = 0;
channel.currentUserMembership.unread_mentions = 0;
});
});
}
get unreadCount() {
let count = 0;
this.publicMessageChannels.forEach((channel) => {

View File

@ -598,6 +598,7 @@ en:
composer_code: "%{shortcut} Code (composer only)"
drawer_open: "%{shortcut} Open chat drawer"
drawer_close: "%{shortcut} Close chat drawer"
mark_all_channels_read: "%{shortcut} Mark all channels read"
topic_statuses:
chat:
help: "Chat is enabled for this topic"

View File

@ -19,6 +19,8 @@
# * +on_model_not_found(name)+: will execute the provided block if the service
# fails and its model is not present
#
# Default actions for each of these are defined in [Chat::BaseController#default_actions_for_service]
#
# @example In a controller
# def create
# with_service MyService do

View File

@ -381,25 +381,6 @@ after_initialize do
end
end
Discourse::Application.routes.append do
mount ::Chat::Engine, at: "/chat"
get "/admin/plugins/chat" => "chat/admin/incoming_webhooks#index",
:constraints => StaffConstraint.new
post "/admin/plugins/chat/hooks" => "chat/admin/incoming_webhooks#create",
:constraints => StaffConstraint.new
put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" =>
"chat/admin/incoming_webhooks#update",
:constraints => StaffConstraint.new
delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" =>
"chat/admin/incoming_webhooks#destroy",
:constraints => StaffConstraint.new
get "u/:username/preferences/chat" => "users#preferences",
:constraints => {
username: RouteFormat.username,
}
end
if defined?(DiscourseAutomation)
add_automation_scriptable("send_chat_message") do
field :chat_channel_id, component: :text, required: true