mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 20:29:55 +08:00
This adds a tip below an embedded zoom livestream to make it clear to the viewer how to join the audio channel. After joining it is hidden. Intentionally hardcoding some color values to match the zoom embed, which doesn't change based on the user's preference for light/dark mode. Before joining audio <img width="450" alt="image" src="https://github.com/user-attachments/assets/7496434a-132e-42d9-8bf8-dbbe5b7c1831" /> Audio joined <img width="450" alt="image" src="https://github.com/user-attachments/assets/fddc280d-6bd2-45a5-bc7e-bffd8929266d" />
1067 lines
35 KiB
Ruby
Vendored
1067 lines
35 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"
|
|
register_svg_icon "zoom-join-audio"
|
|
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 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_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_to_class(:guardian, :can_display_invitee_details?) do |event|
|
|
return true if !event.private? || can_act_on_discourse_post_event?(event)
|
|
|
|
raw_invitees = Array(event.raw_invitees).uniq
|
|
|
|
return true if user && event.user_in_invited_group?(user)
|
|
|
|
return false if raw_invitees.blank?
|
|
|
|
Group
|
|
.visible_groups(user)
|
|
.members_visible_groups(user)
|
|
.where(name: raw_invitees)
|
|
.distinct
|
|
.count == raw_invitees.length
|
|
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 }
|
|
|
|
on(:user_removed_from_group) do |user, group|
|
|
DiscoursePostEvent::Event
|
|
.where(id: DiscoursePostEvent::Invitee.unscoped.where(user_id: user.id).select(:post_id))
|
|
.where(status: DiscoursePostEvent::Event.statuses[:private])
|
|
.where("? = ANY(discourse_post_event_events.raw_invitees)", group.name)
|
|
.find_each(&:enforce_private_invitees!)
|
|
end
|
|
|
|
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? }
|
|
|
|
add_to_serializer(
|
|
:topic_view,
|
|
:event_watching_invitee_status,
|
|
include_condition: -> { scope.user.present? && object.topic.first_post&.event.present? },
|
|
) do
|
|
invitee =
|
|
DiscoursePostEvent::Invitee.find_by(
|
|
post_id: object.topic.first_post.event.id,
|
|
user_id: scope.user.id,
|
|
)
|
|
|
|
DiscoursePostEvent::Invitee.statuses[invitee.status] if invitee
|
|
end
|
|
|
|
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, 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, 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
|