mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 05:42:36 +08:00
This does a handful of things to improve the livestream chat experience: 1. RSVP prompt replaces the default join button. A new chat-channel-preview-card-content plugin outlet in core chat lets the calendar plugin's livestream-rsvp connector fill the redesigned preview card's content slots. For users who can RSVP, it swaps the default join button for a going button for the event. 2. Pinned reference message linking back to the event topic. When a livestream channel is created, a system message is posted and pinned, and its id is stored in a new `reference_message_id` column. It's hidden via CSS when the chat is embedded in that same topic because it would be redundant. 3. Access control for private events. `chat_channel_id` is only serialized to users who can access the event's chat (invitees/invited groups, or admins) 4. If the channel is already open and expanded in the chat drawer, the embedded chat (and the livestream page layout) are suppressed. 5. Livestream channels are created with the spiral calendar emoji 🗓️ as their icon. I also converted core chat's preview card from viewport media queries to container queries so its layout responds to the width of the chat column it's rendered in rather than the viewport. With livestream sidebar <img width="2962" height="1726" alt="image" src="https://github.com/user-attachments/assets/71dc3d3f-35e5-4a0d-9103-92a03d4bb08e" /> Isolated chat when you're not in the livestream topic <img width="2560" height="1726" alt="image" src="https://github.com/user-attachments/assets/51cef0b8-85b4-4be8-a4f3-bf669f4be4fb" /> --------- Co-authored-by: Martin Brennan <martin@discourse.org>
1043 lines
34 KiB
Ruby
Vendored
1043 lines
34 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
# name: discourse-calendar
|
|
# about: Adds the ability to create a dynamic calendar with events in a topic.
|
|
# meta_topic_id: 97376
|
|
# version: 0.5
|
|
# author: Daniel Waterworth, Joffrey Jaffeux
|
|
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-calendar
|
|
|
|
libdir = File.join(File.dirname(__FILE__), "vendor/holidays/lib")
|
|
$LOAD_PATH.unshift(libdir) if $LOAD_PATH.exclude?(libdir)
|
|
|
|
require_relative "lib/calendar_settings_validator"
|
|
require_relative "lib/calendar_custom_fields_validator"
|
|
require_relative "lib/calendar_first_day_of_week"
|
|
require_relative "lib/calendar_upcoming_events_default_view"
|
|
|
|
enabled_site_setting :calendar_enabled
|
|
|
|
register_svg_icon "calendar-days"
|
|
register_asset "stylesheets/common/full-calendar-ext.scss"
|
|
register_asset "stylesheets/common/discourse-calendar.scss"
|
|
register_asset "stylesheets/common/discourse-calendar-holidays.scss"
|
|
register_asset "stylesheets/common/discourse-post-event.scss"
|
|
register_asset "stylesheets/common/discourse-post-event-preview.scss"
|
|
register_asset "stylesheets/common/post-event-builder.scss"
|
|
register_asset "stylesheets/common/discourse-post-event-invitees.scss"
|
|
register_asset "stylesheets/common/composer-event-node-view.scss"
|
|
register_asset "stylesheets/common/discourse-post-event-core-ext.scss"
|
|
register_asset "stylesheets/mobile/discourse-post-event-core-ext.scss", :mobile
|
|
register_asset "stylesheets/common/discourse-post-event-bulk-invite-modal.scss"
|
|
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
|
|
register_asset "stylesheets/mobile/discourse-post-event.scss", :mobile
|
|
register_asset "stylesheets/colors.scss", :color_definitions
|
|
register_asset "stylesheets/common/user-preferences.scss"
|
|
register_asset "stylesheets/common/upcoming-events-list.scss"
|
|
register_asset "stylesheets/common/livestream.scss"
|
|
register_asset "stylesheets/desktop/livestream.scss", :desktop
|
|
register_asset "stylesheets/mobile/livestream.scss", :mobile
|
|
register_svg_icon "calendar-day"
|
|
register_svg_icon "clock"
|
|
register_svg_icon "file-csv"
|
|
register_svg_icon "star"
|
|
register_svg_icon "file-arrow-up"
|
|
register_svg_icon "location-pin"
|
|
register_svg_icon "arrows-up-to-line"
|
|
extend_content_security_policy(worker_src: %w[https://source.zoom.us blob:])
|
|
|
|
module ::DiscourseCalendar
|
|
PLUGIN_NAME = "discourse-calendar"
|
|
|
|
# Type of calendar ('static' or 'dynamic')
|
|
CALENDAR_CUSTOM_FIELD = "calendar"
|
|
|
|
# User custom field set when user is on holiday
|
|
HOLIDAY_CUSTOM_FIELD = "on_holiday"
|
|
|
|
# List of all users on holiday
|
|
USERS_ON_HOLIDAY_KEY = "users_on_holiday"
|
|
|
|
# User region used in finding holidays
|
|
REGION_CUSTOM_FIELD = "holidays-region"
|
|
|
|
# List of groups
|
|
GROUP_TIMEZONES_CUSTOM_FIELD = "group-timezones"
|
|
|
|
module Livestream
|
|
LIVESTREAM_CHAT_STATUS_MESSAGE_BUS_CHANNEL = "/discourse-calendar/livestream/chat-status"
|
|
|
|
def self.handle_topic_chat_channel_creation(topic)
|
|
return if topic.category.blank?
|
|
return if DiscourseCalendar::Livestream::TopicChatChannel.exists?(topic_id: topic.id)
|
|
return unless topic.first_post&.event&.livestream?
|
|
|
|
channel =
|
|
Chat::Channel.create!(
|
|
chatable_id: topic.category.id,
|
|
chatable_type: "Category",
|
|
name: topic.title,
|
|
emoji: "spiral_calendar",
|
|
status: Chat::Channel.statuses[:open],
|
|
type: "CategoryChannel",
|
|
allow_channel_wide_mentions: true,
|
|
)
|
|
|
|
DiscourseCalendar::Livestream::TopicChatChannel.create!(topic: topic, chat_channel: channel)
|
|
channel.user_chat_channel_memberships.create!(user: topic.user, following: false)
|
|
pin_topic_reference_message(topic, channel)
|
|
end
|
|
|
|
def self.pin_topic_reference_message(topic, channel)
|
|
guardian = Discourse.system_user.guardian
|
|
message = nil
|
|
|
|
Chat::CreateMessage.call(
|
|
guardian:,
|
|
params: {
|
|
chat_channel_id: channel.id,
|
|
message:
|
|
I18n.t(
|
|
"discourse_calendar.livestream.chat.topic_reference_message",
|
|
title: topic.markdown_link_title,
|
|
url: topic.relative_url,
|
|
),
|
|
},
|
|
options: {
|
|
enforce_membership: true,
|
|
},
|
|
) do |create_result|
|
|
on_success { |message_instance:| message = message_instance }
|
|
on_failure do
|
|
Rails.logger.warn(
|
|
"Failed to create livestream topic reference message for channel #{channel.id}: #{create_result.inspect_steps}",
|
|
)
|
|
end
|
|
end
|
|
|
|
return if message.blank?
|
|
|
|
DiscourseCalendar::Livestream::TopicChatChannel.where(chat_channel_id: channel.id).update_all(
|
|
reference_message_id: message.id,
|
|
)
|
|
|
|
return if !SiteSetting.chat_pinned_messages
|
|
|
|
Chat::PinMessage.call(
|
|
guardian:,
|
|
params: {
|
|
message_id: message.id,
|
|
channel_id: channel.id,
|
|
},
|
|
) do |pin_result|
|
|
on_failure do
|
|
Rails.logger.warn(
|
|
"Failed to pin livestream topic reference message for channel #{channel.id}: #{pin_result.inspect_steps}",
|
|
)
|
|
end
|
|
end
|
|
rescue StandardError => e
|
|
Rails.logger.warn(
|
|
"Failed to post livestream topic reference message for channel #{channel.id}: #{e.message}",
|
|
)
|
|
end
|
|
|
|
def self.livestream_chat_status_channel(user_id)
|
|
"#{LIVESTREAM_CHAT_STATUS_MESSAGE_BUS_CHANNEL}/#{user_id}"
|
|
end
|
|
|
|
def self.publish_livestream_chat_status(membership, user:)
|
|
MessageBus.publish(
|
|
livestream_chat_status_channel(user.id),
|
|
Chat::UserChannelMembershipSerializer.new(membership, scope: user.guardian).to_json,
|
|
user_ids: [user.id],
|
|
)
|
|
end
|
|
|
|
class ChannelSerializationContext
|
|
def initialize(user)
|
|
@user = user
|
|
end
|
|
|
|
def invitees_by_post_id
|
|
return {} if @user.nil?
|
|
|
|
@invitees_by_post_id ||=
|
|
DiscoursePostEvent::Invitee.where(user_id: @user.id).index_by(&:post_id)
|
|
end
|
|
|
|
def invitee_event_ids
|
|
invitees_by_post_id.keys
|
|
end
|
|
|
|
def group_names
|
|
return [] if @user.nil?
|
|
|
|
@group_names ||= @user.groups.pluck(:name)
|
|
end
|
|
end
|
|
|
|
module ChannelSerializerExtension
|
|
private
|
|
|
|
def livestream_serialization_context
|
|
@livestream_serialization_context ||=
|
|
@options[:livestream_context] ||
|
|
DiscourseCalendar::Livestream::ChannelSerializationContext.new(scope.user)
|
|
end
|
|
|
|
def livestream_invitees_by_post_id
|
|
livestream_serialization_context.invitees_by_post_id
|
|
end
|
|
|
|
def livestream_invitee_event_ids
|
|
livestream_serialization_context.invitee_event_ids
|
|
end
|
|
|
|
def livestream_user_group_names
|
|
livestream_serialization_context.group_names
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.users_on_holiday
|
|
PluginStore.get(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY) || []
|
|
end
|
|
|
|
def self.users_on_holiday=(usernames)
|
|
PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames)
|
|
end
|
|
end
|
|
|
|
module ::DiscoursePostEvent
|
|
PLUGIN_NAME = "discourse-post-event"
|
|
|
|
# Topic where op has a post event custom field
|
|
TOPIC_POST_EVENT_STARTS_AT = "TopicEventStartsAt"
|
|
TOPIC_POST_EVENT_ENDS_AT = "TopicEventEndsAt"
|
|
TOPIC_POST_EVENT_ALL_DAY = "TopicEventAllDay"
|
|
end
|
|
|
|
require_relative "lib/discourse_calendar/engine"
|
|
require_relative "lib/discourse_calendar/livestream/topic_extension"
|
|
require_relative "lib/discourse_calendar/livestream/chat_channel_extension"
|
|
require_relative "lib/discourse_calendar/livestream/zoom_url_parser"
|
|
|
|
Dir
|
|
.glob(File.expand_path("../lib/discourse_calendar/site_settings/*.rb", __FILE__))
|
|
.each { |f| require(f) }
|
|
|
|
after_initialize do
|
|
reloadable_patch do
|
|
register_category_type(DiscourseCalendar::Categories::Types::Events)
|
|
Category.register_custom_field_type("sort_topics_by_event_start_date", :boolean)
|
|
Category.register_custom_field_type("disable_topic_resorting", :boolean)
|
|
register_preloaded_category_custom_fields("sort_topics_by_event_start_date")
|
|
register_preloaded_category_custom_fields("disable_topic_resorting")
|
|
end
|
|
|
|
add_to_serializer :basic_category, :sort_topics_by_event_start_date do
|
|
object.custom_fields["sort_topics_by_event_start_date"]
|
|
end
|
|
|
|
add_to_serializer :basic_category, :disable_topic_resorting do
|
|
object.custom_fields["disable_topic_resorting"]
|
|
end
|
|
|
|
reloadable_patch do
|
|
TopicQuery.add_custom_filter(:order_by_event_date) do |results, topic_query|
|
|
if SiteSetting.sort_categories_by_event_start_date_enabled &&
|
|
topic_query.options[:category_id]
|
|
category = Category.find_by(id: topic_query.options[:category_id])
|
|
if category && category.custom_fields &&
|
|
category.custom_fields["sort_topics_by_event_start_date"]
|
|
reorder_sql = <<~SQL
|
|
CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) > NOW() THEN 0 ELSE 1 END,
|
|
CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) > NOW() THEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) ELSE NULL END,
|
|
CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) < NOW() THEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) ELSE NULL END DESC
|
|
SQL
|
|
results =
|
|
results.joins(
|
|
"LEFT JOIN topic_custom_fields AS custom_fields on custom_fields.topic_id = topics.id
|
|
AND custom_fields.name = '#{DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT}'
|
|
",
|
|
).reorder(reorder_sql)
|
|
end
|
|
end
|
|
results
|
|
end
|
|
end
|
|
|
|
# DISCOURSE CALENDAR HOLIDAYS
|
|
|
|
add_admin_route "admin.calendar", "discourse-calendar", use_new_show_route: true
|
|
|
|
# DISCOURSE POST EVENT
|
|
|
|
require_relative "jobs/regular/discourse_post_event/bulk_invite"
|
|
require_relative "jobs/regular/discourse_post_event/bump_topic"
|
|
require_relative "jobs/regular/discourse_post_event/send_reminder"
|
|
require_relative "jobs/regular/discourse_post_event/warm_livestream_onebox"
|
|
require_relative "lib/discourse_post_event/email_renderer"
|
|
require_relative "lib/discourse_post_event/engine"
|
|
require_relative "lib/discourse_post_event/event_excerpt"
|
|
require_relative "lib/discourse_post_event/event_finder"
|
|
require_relative "lib/discourse_post_event/event_onebox_data"
|
|
require_relative "lib/discourse_post_event/event_parser"
|
|
require_relative "lib/discourse_post_event/event_validator"
|
|
require_relative "lib/discourse_post_event/export_csv_controller_extension"
|
|
require_relative "lib/discourse_post_event/export_csv_file_extension"
|
|
require_relative "lib/discourse_post_event/post_extension"
|
|
require_relative "lib/discourse_post_event/topic_extension"
|
|
require_relative "lib/discourse_post_event/rrule_generator"
|
|
require_relative "lib/discourse_post_event/rrule_configurator"
|
|
require_relative "lib/discourse_post_event/web_hook_extension"
|
|
|
|
::ActionController::Base.prepend_view_path File.expand_path("../app/views", __FILE__)
|
|
|
|
add_api_parameter_route(
|
|
methods: :get,
|
|
actions: "discourse_post_event/events#index",
|
|
formats: :ics,
|
|
)
|
|
|
|
add_user_api_key_scope :events_calendar,
|
|
methods: :get,
|
|
actions: "discourse_post_event/events#index",
|
|
formats: :ics
|
|
|
|
register_calendar_subscription_feed(
|
|
name: "all_events",
|
|
scope: "discourse-calendar:events_calendar",
|
|
description_key: "discourse_calendar.preferences.all_events_description",
|
|
url: ->(base_url, _user, key) do
|
|
"#{base_url}/discourse-post-event/events.ics?user_api_key=#{key}"
|
|
end,
|
|
)
|
|
|
|
register_calendar_subscription_feed(
|
|
name: "my_events",
|
|
scope: "discourse-calendar:events_calendar",
|
|
description_key: "discourse_calendar.preferences.my_events_description",
|
|
url: ->(base_url, user, key) do
|
|
"#{base_url}/discourse-post-event/events.ics?attending_user=#{user.username_lower}&include_interested=true&user_api_key=#{key}"
|
|
end,
|
|
)
|
|
|
|
reloadable_patch do
|
|
ExportCsvController.prepend(DiscoursePostEvent::ExportCsvControllerExtension)
|
|
Jobs::ExportCsvFile.prepend(DiscoursePostEvent::ExportPostEventCsvReportExtension)
|
|
Guardian.prepend(DiscoursePostEvent::GuardianExtension)
|
|
Post.prepend(DiscoursePostEvent::PostExtension)
|
|
::WebHook.prepend(DiscoursePostEvent::WebHookExtension)
|
|
Topic.prepend(DiscoursePostEvent::TopicExtension)
|
|
Topic.prepend(DiscourseCalendar::Livestream::TopicExtension)
|
|
Chat::Channel.prepend(DiscourseCalendar::Livestream::ChatChannelExtension)
|
|
end
|
|
|
|
add_to_class(:user, :can_create_discourse_post_event?) do
|
|
return @can_create_discourse_post_event if defined?(@can_create_discourse_post_event)
|
|
@can_create_discourse_post_event =
|
|
begin
|
|
return true if staff?
|
|
allowed_groups = SiteSetting.discourse_post_event_allowed_on_groups.to_s.split("|").compact
|
|
allowed_groups.present? &&
|
|
(
|
|
allowed_groups.include?(Group::AUTO_GROUPS[:everyone].to_s) ||
|
|
groups.where(id: allowed_groups).exists?
|
|
)
|
|
rescue StandardError
|
|
false
|
|
end
|
|
end
|
|
|
|
add_to_class(:guardian, :can_act_on_invitee?) do |invitee|
|
|
user && (user.id == invitee.user_id || can_act_on_discourse_post_event?(invitee.event))
|
|
end
|
|
|
|
add_to_class(:guardian, :can_create_discourse_post_event?) do
|
|
user && user.can_create_discourse_post_event?
|
|
end
|
|
|
|
add_to_serializer(:current_user, :can_create_discourse_post_event) do
|
|
object.can_create_discourse_post_event?
|
|
end
|
|
|
|
add_to_class(:user, :can_act_on_discourse_post_event?) do |event|
|
|
return true if staff?
|
|
can_create_discourse_post_event? && Guardian.new(self).can_edit_post?(event.post)
|
|
rescue StandardError
|
|
false
|
|
end
|
|
|
|
add_to_class(:guardian, :can_act_on_discourse_post_event?) do |event|
|
|
user && user.can_act_on_discourse_post_event?(event)
|
|
end
|
|
|
|
add_class_method(:group, :discourse_post_event_allowed_groups) do
|
|
where(id: SiteSetting.discourse_post_event_allowed_on_groups.split("|").compact)
|
|
end
|
|
|
|
TopicView.on_preload do |topic_view|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
topic_view.instance_variable_set(:@posts, topic_view.posts.includes(event: :image_upload))
|
|
end
|
|
end
|
|
|
|
add_to_serializer(
|
|
:post,
|
|
:event,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled && !object.nil? && !object.deleted_at.present?
|
|
end,
|
|
) { DiscoursePostEvent::EventSerializer.new(object.event, scope: scope, root: false) }
|
|
|
|
TopicView.on_preload do |topic_view|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
# always set the store (even when empty) and avoid a per-post query for every post in the topic
|
|
topic_view.set_preloaded_post_data(
|
|
:event_oneboxes,
|
|
DiscoursePostEvent::EventOneboxData.build(
|
|
posts: topic_view.posts,
|
|
guardian: topic_view.guardian,
|
|
),
|
|
)
|
|
end
|
|
end
|
|
|
|
add_to_serializer(
|
|
:post,
|
|
:event_oneboxes,
|
|
include_condition: -> { SiteSetting.discourse_post_event_enabled && event_oneboxes.present? },
|
|
) do
|
|
# use the batched topic-view preload on the common read path
|
|
# otherwise compute just this post so the event card shows without a page refresh
|
|
@event_oneboxes ||=
|
|
begin
|
|
preloaded = topic_view&.preloaded_post_data(:event_oneboxes)
|
|
if preloaded
|
|
preloaded[object.id] || {}
|
|
elsif object.cooked&.include?("data-topic")
|
|
DiscoursePostEvent::EventOneboxData.build(posts: [object], guardian: scope)[object.id] ||
|
|
{}
|
|
else
|
|
{}
|
|
end
|
|
end
|
|
end
|
|
|
|
on(:post_created) do |post|
|
|
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
|
|
post.association(:event).reload
|
|
if SiteSetting.discourse_post_event_enabled && post.event
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
|
|
end
|
|
end
|
|
|
|
on(:post_edited) do |post|
|
|
event_before = post.event
|
|
had_image_before = event_before&.image_upload_id.present?
|
|
DiscoursePostEvent::Event::SyncFromPost.call(params: { post_id: post.id })
|
|
post.association(:event).reload
|
|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
if post.event&.image_upload_id
|
|
post.event.sync_image_to_post_and_topic
|
|
elsif had_image_before
|
|
post.trigger_post_process
|
|
end
|
|
DiscoursePostEvent::Event.handle_post_event_webhooks(post, event_before)
|
|
end
|
|
end
|
|
|
|
on(:post_destroyed) do |post|
|
|
if SiteSetting.discourse_post_event_enabled && post.event
|
|
payload = WebHook.build_calendar_event_payload(post.event)
|
|
post.event.update!(deleted_at: Time.now)
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_destroyed, post.event, payload)
|
|
end
|
|
end
|
|
|
|
on(:post_recovered) do |post|
|
|
if SiteSetting.discourse_post_event_enabled && post.event
|
|
post.event.update!(deleted_at: nil)
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
|
|
end
|
|
end
|
|
|
|
add_preloaded_topic_list_custom_field DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_starts_at,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title &&
|
|
object.topic.custom_fields.keys.include?(DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT)
|
|
end,
|
|
) { object.topic.event_starts_at }
|
|
|
|
add_to_class(:topic, :event_starts_at) do
|
|
@event_starts_at ||=
|
|
begin
|
|
value = custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_STARTS_AT].to_s
|
|
Time.find_zone("UTC").parse(value) if value.present?
|
|
end
|
|
end
|
|
|
|
add_to_serializer(
|
|
:topic_list_item,
|
|
:event_starts_at,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title && object.event_starts_at
|
|
end,
|
|
) { object.event_starts_at }
|
|
|
|
add_preloaded_topic_list_custom_field DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_ends_at,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title &&
|
|
object.topic.custom_fields.keys.include?(DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT)
|
|
end,
|
|
) { object.topic.event_ends_at }
|
|
|
|
add_to_class(:topic, :event_ends_at) do
|
|
@event_ends_at ||=
|
|
begin
|
|
value = custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ENDS_AT].to_s
|
|
Time.find_zone("UTC").parse(value) if value.present?
|
|
end
|
|
end
|
|
|
|
add_to_serializer(
|
|
:topic_list_item,
|
|
:event_ends_at,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title && object.event_ends_at
|
|
end,
|
|
) { object.event_ends_at }
|
|
|
|
add_preloaded_topic_list_custom_field DiscoursePostEvent::TOPIC_POST_EVENT_ALL_DAY
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_all_day,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title && object.topic.event_all_day
|
|
end,
|
|
) { object.topic.event_all_day }
|
|
|
|
add_to_class(:topic, :event_all_day) do
|
|
return @event_all_day if defined?(@event_all_day)
|
|
@event_all_day =
|
|
begin
|
|
value = custom_fields[DiscoursePostEvent::TOPIC_POST_EVENT_ALL_DAY].to_s
|
|
ActiveModel::Type::Boolean.new.cast(value)
|
|
end
|
|
end
|
|
|
|
add_to_serializer(
|
|
:topic_list_item,
|
|
:event_all_day,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title && object.event_all_day
|
|
end,
|
|
) { object.event_all_day }
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_timezone,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title &&
|
|
object.topic.first_post&.event&.timezone.present?
|
|
end,
|
|
) { object.topic.first_post.event.timezone }
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_show_local_time,
|
|
include_condition: -> do
|
|
SiteSetting.discourse_post_event_enabled &&
|
|
SiteSetting.display_post_event_date_on_topic_title &&
|
|
object.topic.first_post&.event.present?
|
|
end,
|
|
) { object.topic.first_post.event.show_local_time }
|
|
|
|
# DISCOURSE CALENDAR
|
|
|
|
require_relative "jobs/scheduled/create_holiday_events"
|
|
require_relative "jobs/scheduled/delete_expired_event_posts"
|
|
require_relative "jobs/scheduled/monitor_event_dates"
|
|
require_relative "jobs/scheduled/update_holiday_usernames"
|
|
require_relative "lib/calendar_validator"
|
|
require_relative "lib/calendar"
|
|
require_relative "lib/event_validator"
|
|
require_relative "lib/group_timezones"
|
|
require_relative "lib/holiday_status"
|
|
require_relative "lib/time_sniffer"
|
|
require_relative "lib/users_on_holiday"
|
|
|
|
register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string)
|
|
register_post_custom_field_type(DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD, :json)
|
|
TopicView.default_post_custom_fields << DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD
|
|
|
|
register_user_custom_field_type(DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, :boolean)
|
|
|
|
allow_staff_user_custom_field(DiscourseCalendar::HOLIDAY_CUSTOM_FIELD)
|
|
DiscoursePluginRegistry.serialized_current_user_fields << DiscourseCalendar::REGION_CUSTOM_FIELD
|
|
register_editable_user_custom_field(DiscourseCalendar::REGION_CUSTOM_FIELD)
|
|
register_user_custom_field_type(DiscourseCalendar::REGION_CUSTOM_FIELD, :string, max_length: 40)
|
|
|
|
on(:site_setting_changed) do |name, old_value, new_value|
|
|
next if %i[all_day_event_start_time all_day_event_end_time].exclude? name
|
|
|
|
Post
|
|
.where(id: CalendarEvent.select(:post_id).distinct)
|
|
.each { |post| CalendarEvent.update(post) }
|
|
end
|
|
|
|
on(:post_process_cooked) do |doc, post|
|
|
DiscourseCalendar::Calendar.update(post)
|
|
DiscourseCalendar::GroupTimezones.update(post)
|
|
CalendarEvent.update(post)
|
|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
event = DiscoursePostEvent::Event.find_by(id: post.id)
|
|
event&.sync_image_to_post_and_topic(generate_thumbnails: true) if event&.image_upload_id
|
|
end
|
|
end
|
|
|
|
on(:post_recovered) do |post, _, _|
|
|
DiscourseCalendar::Calendar.update(post)
|
|
DiscourseCalendar::GroupTimezones.update(post)
|
|
CalendarEvent.update(post)
|
|
end
|
|
|
|
on(:post_destroyed) do |post, _, _|
|
|
DiscourseCalendar::Calendar.destroy(post)
|
|
CalendarEvent.where(post_id: post.id).destroy_all
|
|
end
|
|
|
|
validate(:post, :validate_calendar) do |force = nil|
|
|
return unless raw_changed? || force
|
|
|
|
validator = DiscourseCalendar::CalendarValidator.new(self)
|
|
validator.validate_calendar
|
|
end
|
|
|
|
validate(:post, :validate_event) do |force = nil|
|
|
return unless raw_changed? || force
|
|
return if is_first_post?
|
|
|
|
# Skip if not a calendar topic
|
|
return if !topic&.first_post&.custom_fields&.[](DiscourseCalendar::CALENDAR_CUSTOM_FIELD)
|
|
|
|
validator = DiscourseCalendar::EventValidator.new(self)
|
|
validator.validate_event
|
|
end
|
|
|
|
add_to_class(:post, :has_group_timezones?) do
|
|
custom_fields[DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD].present?
|
|
end
|
|
|
|
add_to_class(:post, :group_timezones) do
|
|
custom_fields[DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD] || {}
|
|
end
|
|
|
|
add_to_class(:post, :group_timezones=) do |val|
|
|
if val.present?
|
|
custom_fields[DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD] = val
|
|
else
|
|
custom_fields.delete(DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD)
|
|
end
|
|
end
|
|
|
|
add_to_serializer(:post, :calendar_details, include_condition: -> { object.is_first_post? }) do
|
|
start_date = 6.months.ago
|
|
|
|
standalone_sql = <<~SQL
|
|
SELECT post_number, description, start_date, end_date, username, recurrence, timezone
|
|
FROM calendar_events
|
|
WHERE topic_id = :topic_id
|
|
AND post_id IS NOT NULL
|
|
ORDER BY start_date, end_date
|
|
SQL
|
|
|
|
standalones =
|
|
DB
|
|
.query(standalone_sql, topic_id: object.topic_id)
|
|
.map do |row|
|
|
{
|
|
type: :standalone,
|
|
post_number: row.post_number,
|
|
message: row.description,
|
|
from: row.start_date,
|
|
to: row.end_date,
|
|
username: row.username,
|
|
recurring: row.recurrence,
|
|
post_url: Post.url("-", object.topic_id, row.post_number),
|
|
timezone: row.timezone,
|
|
}
|
|
end
|
|
|
|
timezones =
|
|
UserOption
|
|
.where(
|
|
user_id:
|
|
CalendarEvent.where(
|
|
topic_id: object.topic_id,
|
|
post_id: nil,
|
|
start_date: start_date..,
|
|
).select(:user_id),
|
|
)
|
|
.where("LENGTH(COALESCE(timezone, '')) > 0")
|
|
.pluck(:user_id, :timezone)
|
|
.to_h
|
|
|
|
grouped = {}
|
|
|
|
grouped_sql = <<~SQL
|
|
SELECT region, start_date, timezone, user_id, username, description
|
|
FROM calendar_events
|
|
WHERE topic_id = :topic_id
|
|
AND post_id IS NULL
|
|
AND start_date >= :start_date
|
|
ORDER BY region, start_date
|
|
SQL
|
|
|
|
DB
|
|
.query(grouped_sql, topic_id: object.topic_id, start_date: start_date)
|
|
.each do |row|
|
|
identifier = "#{row.region.split("_").first}-#{row.start_date.strftime("%Y-%j")}"
|
|
|
|
grouped[identifier] ||= {
|
|
type: :grouped,
|
|
from: row.start_date,
|
|
timezone: row.timezone,
|
|
name: [],
|
|
users: [],
|
|
}
|
|
|
|
grouped[identifier][:name] << row.description
|
|
grouped[identifier][:users] << { username: row.username, timezone: timezones[row.user_id] }
|
|
end
|
|
|
|
grouped.each do |_, v|
|
|
v[:name].uniq!
|
|
v[:name].sort!
|
|
v[:name] = v[:name].join(", ")
|
|
v[:users].uniq! { |u| u[:username] }
|
|
v[:users].sort! { |a, b| a[:username] <=> b[:username] }
|
|
end
|
|
|
|
standalones + grouped.values
|
|
end
|
|
|
|
add_to_serializer(
|
|
:post,
|
|
:group_timezones,
|
|
include_condition: -> do
|
|
post_custom_fields[DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD].present?
|
|
end,
|
|
) do
|
|
result = {}
|
|
group_timezones = post_custom_fields[DiscourseCalendar::GROUP_TIMEZONES_CUSTOM_FIELD] || {}
|
|
group_names = group_timezones["groups"] || []
|
|
|
|
if group_names.present?
|
|
visible_group_ids =
|
|
Group
|
|
.where(name: group_names)
|
|
.visible_groups(scope.user)
|
|
.members_visible_groups(scope.user)
|
|
.select(:id)
|
|
|
|
users =
|
|
User
|
|
.human_users
|
|
.joins(:groups, :user_option)
|
|
.where(groups: { id: visible_group_ids })
|
|
.select("users.*", "groups.name AS group_name", "user_options.timezone")
|
|
|
|
usernames_on_holiday = DiscourseCalendar.users_on_holiday
|
|
|
|
users.each do |u|
|
|
result[u.group_name] ||= []
|
|
result[u.group_name] << UserTimezoneSerializer.new(
|
|
u,
|
|
root: false,
|
|
on_holiday: usernames_on_holiday&.include?(u.username),
|
|
).as_json
|
|
end
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
add_to_serializer(:site, :users_on_holiday, include_condition: -> { scope.is_staff? }) do
|
|
DiscourseCalendar.users_on_holiday
|
|
end
|
|
|
|
on(:reduce_cooked) do |fragment, post|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
fragment
|
|
.css(".discourse-post-event")
|
|
.each do |event_node|
|
|
event_node.replace(DiscoursePostEvent::EmailRenderer.render(event_node, post))
|
|
rescue => e
|
|
Discourse.warn_exception(
|
|
e,
|
|
message: "Failed to render event in email for post #{post&.id}",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
on(:reduce_excerpt) do |fragment, options|
|
|
if SiteSetting.discourse_post_event_enabled
|
|
DiscoursePostEvent::EventExcerpt.call(fragment, post: options[:post])
|
|
end
|
|
end
|
|
|
|
on(:user_destroyed) { |user| DiscoursePostEvent::Invitee.where(user_id: user.id).destroy_all }
|
|
|
|
add_post_revision_notifier_recipients do |post_revision|
|
|
# next if no modifications
|
|
next if !post_revision.modifications.present?
|
|
|
|
# do no notify recipients when only updating tags
|
|
next if post_revision.modifications.keys == ["tags"]
|
|
|
|
ids = []
|
|
post = post_revision.post
|
|
|
|
if post && post.is_first_post? && post.event
|
|
ids.concat(post.event.on_going_event_invitees.pluck(:user_id))
|
|
end
|
|
|
|
ids
|
|
end
|
|
|
|
on(:site_setting_changed) do |name, old_val, new_val|
|
|
next if name != :discourse_post_event_allowed_custom_fields
|
|
|
|
previous_fields = old_val.split("|")
|
|
new_fields = new_val.split("|")
|
|
removed_fields = previous_fields - new_fields
|
|
|
|
next if removed_fields.empty?
|
|
|
|
DiscoursePostEvent::Event.all.find_each do |event|
|
|
removed_fields.each { |field| event.custom_fields.delete(field) }
|
|
event.save
|
|
end
|
|
end
|
|
|
|
if defined?(DiscourseAutomation)
|
|
on(:discourse_post_event_event_started) do |event|
|
|
DiscourseAutomation::Automation
|
|
.where(enabled: true, trigger: "event_started")
|
|
.each do |automation|
|
|
fields = automation.serialized_fields
|
|
topic_id = fields.dig("topic_id", "value")
|
|
|
|
next unless event.post.topic.id.to_s == topic_id
|
|
|
|
automation.trigger!(
|
|
"kind" => "event_started",
|
|
"event" => event,
|
|
"placeholders" => {
|
|
"event_url" => event.url,
|
|
},
|
|
)
|
|
end
|
|
end
|
|
|
|
add_triggerable_to_scriptable("event_started", "send_chat_message")
|
|
|
|
add_automation_triggerable("event_started") do
|
|
placeholder :event_url
|
|
|
|
field :topic_id, component: :text
|
|
end
|
|
end
|
|
|
|
query =
|
|
Proc.new do |notifications, data|
|
|
notifications.where("data::json ->> 'topic_title' = ?", data[:topic_title].to_s).where(
|
|
"data::json ->> 'message' = ?",
|
|
data[:message].to_s,
|
|
)
|
|
end
|
|
|
|
reminders_consolidation_plan =
|
|
Notifications::DeletePreviousNotifications.new(
|
|
type: Notification.types[:event_reminder],
|
|
previous_query_blk: query,
|
|
)
|
|
|
|
invitation_consolidation_plan =
|
|
Notifications::DeletePreviousNotifications.new(
|
|
type: Notification.types[:event_invitation],
|
|
previous_query_blk: query,
|
|
)
|
|
|
|
register_notification_consolidation_plan(reminders_consolidation_plan)
|
|
register_notification_consolidation_plan(invitation_consolidation_plan)
|
|
|
|
Report.add_report("currently_away") do |report|
|
|
group_filter = report.filters.dig(:group) || Group::AUTO_GROUPS[:staff]
|
|
report.add_filter("group", type: "group", default: group_filter)
|
|
|
|
break unless group = Group.find_by(id: group_filter)
|
|
|
|
report.labels = [
|
|
{ property: :username, title: I18n.t("reports.currently_away.labels.username") },
|
|
]
|
|
|
|
group_usernames = group.users.pluck(:username)
|
|
on_holiday_usernames = DiscourseCalendar.users_on_holiday
|
|
report.data = (group_usernames & on_holiday_usernames).map { |username| { username: username } }
|
|
report.total = report.data.count
|
|
end
|
|
|
|
register_anonymous_action("rsvp_event") do |user, params|
|
|
event_id = params["event_id"]
|
|
recurring = ActiveModel::Type::Boolean.new.cast(params["recurring"])
|
|
existing_invitee = DiscoursePostEvent::Invitee.find_by(post_id: event_id, user_id: user.id)
|
|
|
|
if existing_invitee
|
|
DiscoursePostEvent::UpdateInvitee.call(
|
|
params: {
|
|
event_id: event_id,
|
|
invitee_id: existing_invitee.id,
|
|
status: params["status"],
|
|
recurring: recurring,
|
|
},
|
|
guardian: user.guardian,
|
|
)
|
|
else
|
|
DiscoursePostEvent::CreateInvitee.call(
|
|
params: {
|
|
event_id: event_id,
|
|
status: params["status"],
|
|
recurring: recurring,
|
|
user_id: user.id,
|
|
},
|
|
guardian: user.guardian,
|
|
)
|
|
end
|
|
end
|
|
|
|
# DISCOURSE LIVESTREAM
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:chat_channel_id,
|
|
include_condition: -> do
|
|
event = object.topic.first_post&.event
|
|
event&.livestream? && object.topic.topic_chat_channel.present? &&
|
|
(scope.is_admin? || event.can_access_livestream_chat?(scope.user))
|
|
end,
|
|
) { object.topic.topic_chat_channel.chat_channel_id }
|
|
|
|
add_to_serializer(:topic_view, :has_livestream) { object.topic.first_post&.event&.livestream? }
|
|
|
|
Chat::ChannelSerializer.include(DiscourseCalendar::Livestream::ChannelSerializerExtension)
|
|
|
|
register_modifier(:chat_channel_fetcher_public_includes) do |includes|
|
|
includes + [{ livestream_topic_chat_channel: { topic: { first_post: :event } } }]
|
|
end
|
|
|
|
register_modifier(:chat_channel_serializer_public_options) do |serializer_options, guardian|
|
|
serializer_options.merge(
|
|
livestream_context:
|
|
DiscourseCalendar::Livestream::ChannelSerializationContext.new(guardian.user),
|
|
)
|
|
end
|
|
|
|
add_to_serializer(
|
|
"Chat::Channel",
|
|
:livestream_topic,
|
|
include_condition: -> do
|
|
return false if object.chatable_type != "Category"
|
|
|
|
topic = object.livestream_topic_chat_channel&.topic
|
|
event = topic&.first_post&.event
|
|
|
|
return false if !event
|
|
|
|
event.livestream? &&
|
|
event.can_access_livestream_chat?(
|
|
scope.user,
|
|
invitee_event_ids: livestream_invitee_event_ids,
|
|
group_names: livestream_user_group_names,
|
|
)
|
|
end,
|
|
) do
|
|
topic = object.livestream_topic_chat_channel.topic
|
|
event = topic.first_post&.event
|
|
watching_invitee = livestream_invitees_by_post_id[event&.id]
|
|
|
|
can_update_attendance =
|
|
if scope.anonymous? || !event
|
|
false
|
|
else
|
|
event.can_user_update_attendance?(
|
|
scope.user,
|
|
invitee_event_ids: livestream_invitee_event_ids,
|
|
group_names: livestream_user_group_names,
|
|
)
|
|
end
|
|
|
|
{
|
|
id: topic.id,
|
|
title: topic.title,
|
|
slug: topic.slug,
|
|
url: topic.relative_url,
|
|
event_id: topic.first_post&.id,
|
|
reference_message_id: object.livestream_topic_chat_channel.reference_message_id,
|
|
can_update_attendance: can_update_attendance,
|
|
watching_invitee_status:
|
|
watching_invitee && DiscoursePostEvent::Invitee.statuses[watching_invitee.status],
|
|
}
|
|
end
|
|
|
|
on(:chat_channel_trashed) do |channel, user|
|
|
# If the chat channel is deleted, delete the related TopicChatChannel record
|
|
DiscourseCalendar::Livestream::TopicChatChannel.where(chat_channel_id: channel.id).destroy_all
|
|
end
|
|
|
|
on(:discourse_calendar_post_event_invitee_status_changed) do |invitee|
|
|
topic = invitee.event.post.topic
|
|
topic_chat_channel = topic.topic_chat_channel
|
|
|
|
next if !topic_chat_channel
|
|
|
|
user = User.find(invitee.user_id)
|
|
channel = topic_chat_channel.chat_channel
|
|
manager = Chat::ChannelMembershipManager.new(channel)
|
|
|
|
# Attendance is the chat gate: anyone going is auto-followed into the
|
|
# livestream channel, anyone else is unfollowed.
|
|
membership =
|
|
if invitee.status == DiscoursePostEvent::Invitee.statuses[:going]
|
|
manager.follow(user)
|
|
else
|
|
manager.unfollow(user)
|
|
end
|
|
|
|
if membership
|
|
DiscourseCalendar::Livestream.publish_livestream_chat_status(membership, user: user)
|
|
end
|
|
end
|
|
end
|