0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 05:42:36 +08:00
discourse/plugins/discourse-calendar/plugin.rb
Kris dd2c5ba0f8
UX: improve livestream chat messaging (#41750)
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>
2026-07-21 11:07:31 +10:00

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