2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Move discourse-calendar to core (#33570)

https://meta.discourse.org/t/373574

Internal `/t/-/156778`
This commit is contained in:
Jarek Radosz 2025-07-15 14:14:01 +02:00
parent 48a1f9cd2f
commit a925474ed0
764 changed files with 79331 additions and 0 deletions

4
.github/labeler.yml vendored
View file

@ -106,6 +106,10 @@ discourse-gamification:
- changed-files:
- any-glob-to-any-file: plugins/discourse-gamification/**/*

discourse-calendar:
- changed-files:
- any-glob-to-any-file: plugins/discourse-calendar/**/*

footnote:
- changed-files:
- any-glob-to-any-file: plugins/footnote/**/*

1
.gitignore vendored
View file

@ -69,6 +69,7 @@
!/plugins/discourse-subscriptions
!/plugins/discourse-hcaptcha
!/plugins/discourse-gamification
!/plugins/discourse-calendar
/plugins/*/auto_generated

/spec/fixtures/plugins/my_plugin/auto_generated

View file

@ -1,2 +1,3 @@
--print-width=100
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
--ignore-files=plugins/discourse-calendar/vendor/*

View file

@ -0,0 +1,2 @@
assets/stylesheets/vendor/*.scss
public/

View file

@ -0,0 +1,42 @@
# Discourse Calendar

Adds the ability to create a dynamic calendar in the first post of a topic.

Topic discussing the plugin itself can be found here: [https://meta.discourse.org/t/discourse-calendar/97376](https://meta.discourse.org/t/discourse-calendar/97376)

## Customization

### Events

- `discourse_post_event_event_will_start` this DiscourseEvent will be triggered one hour before an event starts
- `discourse_post_event_event_started` this DiscourseEvent will be triggered when an event starts
- `discourse_post_event_event_ended` this DiscourseEvent will be triggered when an event ends

### Custom Fields

Custom fields can be set in plugin settings. Once added a new form will appear on event UI.
These custom fields are available when a plugin event is triggered.

### Holidays

See an incorrect or missing holiday? Familiarize yourself with the [holiday definition Syntax](vendor/holidays/definitions/doc/SYNTAX.md). Then make your updates in the `vendor/holiday/definitions` directory.

Generate updated holidays as follows.

```sh
cd vendor/holidays

# Generate holiday definitions
rake generate:definitions
```

Install the plugin and switch to the discourse root(not the plugin directory).

```sh
# Collect all holiday regions into assets/javascripts/lib/regions.js
bin/rails javascript:update_constants
```

### Interactions with Other Plugins

You can use an element of this plugin with the [Right Sidebar Blocks](https://github.com/discourse/discourse-right-sidebar-blocks) component. You'll want to ensure the desired route is enabled via the `events calendar categories` setting. In Right Sidebar Block's settings, the block name will be `upcoming-events-list`, and the params use this [syntax](https://momentjs.com/docs/#/displaying/format/), for example `MMMM D, YYYY`.

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true

module Admin::DiscourseCalendar
class AdminHolidaysController < Admin::AdminController
requires_plugin DiscourseCalendar::PLUGIN_NAME

def index
region_code = params[:region_code]

begin
holidays = DiscourseCalendar::Holiday.find_holidays_for(region_code: region_code)
rescue Holidays::InvalidRegion
return(
render_json_error(
I18n.t("system_messages.discourse_calendar_holiday_region_invalid"),
422,
)
)
end

render json: { region_code: region_code, holidays: holidays }
end

def disable
DiscourseCalendar::DisabledHoliday.create!(disabled_holiday_params)
CalendarEvent.destroy_by(
description: disabled_holiday_params[:holiday_name],
region: disabled_holiday_params[:region_code],
)

render json: success_json
end

def enable
if DiscourseCalendar::DisabledHoliday.destroy_by(disabled_holiday_params).present?
render json: success_json
else
render_json_error(I18n.t("system_messages.discourse_calendar_enable_holiday_failed"), 422)
end
end

private

def disabled_holiday_params
params.require(:disabled_holiday).permit(:holiday_name, :region_code)
end
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true

module DiscoursePostEvent
class DiscoursePostEventController < ::ApplicationController
requires_plugin DiscourseCalendar::PLUGIN_NAME
before_action :ensure_discourse_post_event_enabled

private

def ensure_discourse_post_event_enabled
raise Discourse::NotFound if !SiteSetting.discourse_post_event_enabled
end
end
end

View file

@ -0,0 +1,130 @@
# frozen_string_literal: true

module DiscoursePostEvent
class EventsController < DiscoursePostEventController
def index
@events =
DiscoursePostEvent::EventFinder.search(current_user, filtered_events_params).includes(
post: :topic,
)

# The detailed serializer is currently not used anywhere in the frontend, but available via API
serializer = params[:include_details] == "true" ? EventSerializer : EventSummarySerializer

render json:
ActiveModel::ArraySerializer.new(
@events,
each_serializer: serializer,
scope: guardian,
).as_json
end

def invite
event = Event.find(params[:id])
guardian.ensure_can_act_on_discourse_post_event!(event)
invites = Array(params.permit(invites: [])[:invites])
users = User.real.where(username: invites)

users.each { |user| event.create_notification!(user, event.post) }

render json: success_json
end

def show
event = Event.find(params[:id])
guardian.ensure_can_see!(event.post)
serializer = EventSerializer.new(event, scope: guardian)
render_json_dump(serializer)
end

def destroy
event = Event.find(params[:id])
guardian.ensure_can_act_on_discourse_post_event!(event)
event.publish_update!
event.destroy
render json: success_json
end

def csv_bulk_invite
require "csv"

event = Event.find(params[:id])
guardian.ensure_can_edit!(event.post)
guardian.ensure_can_create_discourse_post_event!

file = params[:file] || (params[:files] || []).first
raise Discourse::InvalidParameters.new(:file) if file.blank?

hijack do
begin
invitees = []

CSV.foreach(file.tempfile) do |row|
invitees << { identifier: row[0], attendance: row[1] || "going" } if row[0].present?
end

if invitees.present?
Jobs.enqueue(
:discourse_post_event_bulk_invite,
event_id: event.id,
invitees: invitees,
current_user_id: current_user.id,
)
render json: success_json
else
render json:
failed_json.merge(
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
),
status: 422
end
rescue StandardError
render json:
failed_json.merge(
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
),
status: 422
end
end
end

def bulk_invite
event = Event.find(params[:id])
guardian.ensure_can_edit!(event.post)
guardian.ensure_can_create_discourse_post_event!

invitees = Array(params[:invitees]).reject { |x| x.empty? }
raise Discourse::InvalidParameters.new(:invitees) if invitees.blank?

begin
Jobs.enqueue(
:discourse_post_event_bulk_invite,
event_id: event.id,
invitees: invitees.as_json,
current_user_id: current_user.id,
)
render json: success_json
rescue StandardError
render json:
failed_json.merge(
errors: [I18n.t("discourse_post_event.errors.bulk_invite.error")],
),
status: 422
end
end

private

def filtered_events_params
params.permit(
:post_id,
:category_id,
:include_subcategories,
:include_expired,
:limit,
:before,
:attending_user,
)
end
end
end

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true

module DiscoursePostEvent
class InviteesController < DiscoursePostEventController
def index
event = Event.find(params[:post_id])
guardian.ensure_can_see!(event.post)

filter = params[:filter].downcase if params[:filter]

event_invitees = event.invitees
event_invitees = event_invitees.with_status(params[:type].to_sym) if params[:type]

suggested_users = []
if filter.present? && guardian.can_act_on_discourse_post_event?(event)
missing_users = event.missing_users(event_invitees.select(:user_id))

if filter
missing_users = missing_users.where("LOWER(username) LIKE :filter", filter: "%#{filter}%")

custom_order = <<~SQL
CASE
WHEN LOWER(username) = ? THEN 0
ELSE 1
END ASC,
LOWER(username) ASC
SQL

custom_order = ActiveRecord::Base.sanitize_sql_array([custom_order, filter])
missing_users = missing_users.order(custom_order).limit(10)
else
missing_users = missing_users.order(:username_lower).limit(10)
end

suggested_users = missing_users
end

if filter
event_invitees =
event_invitees.joins(:user).where(
"LOWER(users.username) LIKE :filter",
filter: "%#{filter}%",
)
end

event_invitees = event_invitees.order(%i[status username_lower]).limit(200)

render json:
InviteeListSerializer.new(invitees: event_invitees, suggested_users: suggested_users)
end

def update
invitee = Invitee.find_by(id: params[:invitee_id], post_id: params[:event_id])
guardian.ensure_can_act_on_invitee!(invitee)
invitee.update_attendance!(invitee_params[:status])
render json: InviteeSerializer.new(invitee)
end

def create
event = Event.find(params[:event_id])
guardian.ensure_can_see!(event.post)

invitee_params = invitee_params(event)

user = current_user
if user_id = invitee_params[:user_id]
user = User.find(user_id.to_i)
end

raise Discourse::InvalidAccess if !event.can_user_update_attendance(user)

if current_user.id != user.id
raise Discourse::InvalidAccess if !guardian.can_act_on_discourse_post_event?(event)
end

invitee = Invitee.create_attendance!(user.id, params[:event_id], invitee_params[:status])
render json: InviteeSerializer.new(invitee)
end

def destroy
event = Event.find_by(id: params[:post_id])
invitee = event.invitees.find_by(id: params[:id])
guardian.ensure_can_act_on_invitee!(invitee)
invitee.destroy!
event.publish_update!
render json: success_json
end

private

def invitee_params(event = nil)
if event && guardian.can_act_on_discourse_post_event?(event)
params.require(:invitee).permit(:status, :user_id)
else
params.require(:invitee).permit(:status)
end
end
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true

module DiscoursePostEvent
class UpcomingEventsController < DiscoursePostEventController
def index
end
end
end

View file

@ -0,0 +1,131 @@
# frozen_string_literal: true

class CalendarEvent < ActiveRecord::Base
belongs_to :topic
belongs_to :post
belongs_to :user

after_save do
if SiteSetting.enable_user_status && is_holiday? && underway?
DiscourseCalendar::HolidayStatus.set!(user, ends_at)
end
end

after_destroy { DiscourseCalendar::HolidayStatus.clear!(user) if SiteSetting.enable_user_status }

def ends_at
end_date || (start_date + 24.hours)
end

def underway?
now = Time.zone.now
start_date <= now && now < ends_at
end

def is_holiday?
SiteSetting.holiday_calendar_topic_id.to_i == topic_id
end

def in_future?
start_date > Time.zone.now
end

def self.update(post)
CalendarEvent.where(post_id: post.id).destroy_all

dates = post.local_dates
return if !dates || dates.size < 1 || dates.size > 2

first_post = post.topic&.first_post
return if !first_post || !first_post.custom_fields[DiscourseCalendar::CALENDAR_CUSTOM_FIELD]

from = self.convert_to_date_time(dates[0])
to = self.convert_to_date_time(dates[1]) if dates.size == 2

adjust_to = !to || !dates[1]["time"]
if !to && dates[0]["time"]
to = from + 1.hour
artificial_to = true
end

if SiteSetting.all_day_event_start_time.present? && SiteSetting.all_day_event_end_time.present?
from = from.change(hour_adjustment(SiteSetting.all_day_event_start_time)) if !dates[0]["time"]
to = (to || from).change(hour_adjustment(SiteSetting.all_day_event_end_time)) if adjust_to &&
!artificial_to
end

doc = Nokogiri::HTML5.fragment(post.cooked)
doc.css(".discourse-local-date").each(&:remove)
html = doc.to_html.sub(/\s*→\s*/, "")

description =
PrettyText.excerpt(
html,
1000,
strip_links: true,
text_entities: true,
keep_emoji_images: true,
)
recurrence = dates[0]["recurring"].presence
timezone = dates[0]["timezone"].presence

CalendarEvent.create!(
topic_id: post.topic_id,
post_id: post.id,
post_number: post.post_number,
user_id: post.user_id,
username: post.user.username,
description: description,
start_date: from,
end_date: to,
recurrence: recurrence,
timezone: timezone,
)

post.publish_change_to_clients!(:calendar_change)
end

private

def self.convert_to_date_time(value)
return if value.blank?

datetime = value["date"].to_s
datetime << " #{value["time"]}" if value["time"]
timezone = value["timezone"] || "UTC"

ActiveSupport::TimeZone[timezone].parse(datetime)
end

def self.hour_adjustment(setting)
setting = setting.split(":")

{ hour: setting.first, min: setting.last }
end
end

# == Schema Information
#
# Table name: calendar_events
#
# id :bigint not null, primary key
# topic_id :integer not null
# post_id :integer
# post_number :integer
# user_id :integer
# username :string
# description :string
# start_date :datetime not null
# end_date :datetime
# recurrence :string
# region :string
# created_at :datetime not null
# updated_at :datetime not null
# timezone :string
#
# Indexes
#
# index_calendar_events_on_post_id (post_id)
# index_calendar_events_on_topic_id (topic_id)
# index_calendar_events_on_user_id (user_id)
#

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true

module DiscourseCalendar
class DisabledHoliday < ActiveRecord::Base
validates :holiday_name, presence: true
validates :region_code, presence: true
end
end

# == Schema Information
#
# Table name: discourse_calendar_disabled_holidays
#
# id :bigint not null, primary key
# holiday_name :string not null
# region_code :string not null
# disabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_disabled_holidays_on_holiday_name_and_region_code (holiday_name,region_code)
#

View file

@ -0,0 +1,437 @@
# frozen_string_literal: true

module DiscoursePostEvent
class Event < ActiveRecord::Base
PUBLIC_GROUP = "trust_level_0"
MIN_NAME_LENGTH = 5
MAX_NAME_LENGTH = 255
self.table_name = "discourse_post_event_events"
self.ignored_columns = %w[starts_at ends_at]

has_many :event_dates, dependent: :destroy
# this is a cross plugin dependency, only called if chat is enabled
belongs_to :chat_channel, class_name: "Chat::Channel"
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
belongs_to :post, foreign_key: :id

scope :visible, -> { where(deleted_at: nil) }

after_commit :destroy_topic_custom_field, on: %i[destroy]
after_commit :create_or_update_event_date, on: %i[create update]
before_save :chat_channel_sync

validate :raw_invitees_are_groups
validates :original_starts_at, presence: true
validates :name,
length: {
in: MIN_NAME_LENGTH..MAX_NAME_LENGTH,
},
unless: ->(event) { event.name.blank? }

validate :raw_invitees_length
validate :ends_before_start
validate :allowed_custom_fields

def self.attributes_protected_by_default
super - %w[id]
end

def destroy_topic_custom_field
if self.post && self.post.is_first_post?
TopicCustomField.where(
topic_id: self.post.topic_id,
name: TOPIC_POST_EVENT_STARTS_AT,
).delete_all

TopicCustomField.where(
topic_id: self.post.topic_id,
name: TOPIC_POST_EVENT_ENDS_AT,
).delete_all
end
end

def create_or_update_event_date
starts_at_changed = saved_change_to_original_starts_at
ends_at_changed = saved_change_to_original_ends_at

return if !starts_at_changed && !ends_at_changed

event_dates.update_all(finished_at: Time.current)
set_next_date
end

def set_next_date
next_dates = calculate_next_date
return if !next_dates

event_dates.create!(
starts_at: next_dates[:starts_at],
ends_at: next_dates[:ends_at],
) do |event_date|
if next_dates[:ends_at] && next_dates[:ends_at] < Time.current
event_date.finished_at = next_dates[:ends_at]
end
end

invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false)

if !next_dates[:rescheduled]
notify_invitees!
notify_missing_invitees!
end

publish_update!
end

def set_topic_bump
date = nil

return if reminders.blank?
reminders
.split(",")
.each do |reminder|
type, value, unit = reminder.split(".")
next if type != "bumpTopic" || !validate_reminder_unit(unit)
date = starts_at - value.to_i.public_send(unit)
break
end

return if date.blank?
Jobs.enqueue(:discourse_post_event_bump_topic, topic_id: self.post.topic_id, date: date)
end

def validate_reminder_unit(input)
ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input }
end

def expired?
(ends_at || starts_at.end_of_day) <= Time.now
end

def starts_at
event_dates.pending.order(:starts_at).last&.starts_at ||
event_dates.order(:updated_at, :id).last&.starts_at
end

def ends_at
event_dates.pending.order(:starts_at).last&.ends_at ||
event_dates.order(:updated_at, :id).last&.ends_at
end

def on_going_event_invitees
return [] if !self.ends_at && self.starts_at < Time.now

if self.ends_at
extended_ends_at =
self.ends_at + SiteSetting.discourse_post_event_edit_notifications_time_extension.minutes
return [] if !(self.starts_at..extended_ends_at).cover?(Time.now)
end

invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going])
end

def raw_invitees_length
if self.raw_invitees && self.raw_invitees.length > 10
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.raw_invitees_length", count: 10),
)
end
end

def raw_invitees_are_groups
if self.raw_invitees && User.select(:id).where(username: self.raw_invitees).limit(1).count > 0
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.raw_invitees.only_group"),
)
end
end

def ends_before_start
if self.original_starts_at && self.original_ends_at &&
self.original_starts_at >= self.original_ends_at
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at"),
)
end
end

def allowed_custom_fields
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|")
self.custom_fields.each do |key, value|
if !allowed_custom_fields.include?(key)
errors.add(
:base,
I18n.t("discourse_post_event.errors.models.event.custom_field_is_invalid", field: key),
)
end
end
end

def create_invitees(attrs)
timestamp = Time.now
attrs.map! do |attr|
{ post_id: self.id, created_at: timestamp, updated_at: timestamp }.merge(attr)
end
result = self.invitees.insert_all!(attrs)

# batch event does not call calleback
ChatChannelSync.sync(self) if chat_enabled?

result
end

def notify_invitees!(predefined_attendance: false)
self
.invitees
.where(notified: false)
.find_each do |invitee|
create_notification!(
invitee.user,
self.post,
predefined_attendance: predefined_attendance,
)
invitee.update!(notified: true)
end
end

def notify_missing_invitees!
self.missing_users.each { |user| create_notification!(user, self.post) } if self.private?
end

def create_notification!(user, post, predefined_attendance: false)
return if post.event.starts_at < Time.current

message =
if predefined_attendance
"discourse_post_event.notifications.invite_user_predefined_attendance_notification"
else
"discourse_post_event.notifications.invite_user_notification"
end

attrs = {
notification_type: Notification.types[:event_invitation] || Notification.types[:custom],
topic_id: post.topic_id,
post_number: post.post_number,
data: {
user_id: user.id,
topic_title: self.name || post.topic.title,
display_username: post.user.username,
message: message,
}.to_json,
}

user.notifications.consolidate_or_create!(attrs)
end

def ongoing?
return false if self.closed || self.expired?
finishes_at = self.ends_at || self.starts_at.end_of_day
(self.starts_at..finishes_at).cover?(Time.now)
end

def self.statuses
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
end

def public?
status == Event.statuses[:public]
end

def standalone?
status == Event.statuses[:standalone]
end

def private?
status == Event.statuses[:private]
end

def recurring?
recurrence.present?
end

def most_likely_going(limit = SiteSetting.displayed_invitees_limit)
going = self.invitees.order(%i[status user_id]).limit(limit)

if self.private? && going.count < limit
# invitees are only created when an attendance is set
# so we create a dummy invitee object with only what's needed for serializer
going =
going +
missing_users(going.pluck(:user_id))
.limit(limit - going.count)
.map { |user| Invitee.new(user: user, post_id: self.id) }
end

going
end

def publish_update!
self.post.publish_message!("/discourse-post-event/#{self.post.topic_id}", id: self.id)
end

def fetch_users
@fetched_users ||= Invitee.extract_uniq_usernames(self.raw_invitees)
end

def enforce_private_invitees!
self.invitees.where.not(user_id: fetch_users.select(:id)).delete_all
end

def can_user_update_attendance(user)
return false if self.closed || self.expired?
return true if self.public?

self.private? &&
(
self.invitees.exists?(user_id: user.id) ||
(user.groups.pluck(:name) & self.raw_invitees).any?
)
end

def self.update_from_raw(post)
events = DiscoursePostEvent::EventParser.extract_events(post)

if events.present?
event_params = events.first
event = post.event || DiscoursePostEvent::Event.new(id: post.id)

tz = ActiveSupport::TimeZone[event_params[:timezone] || "UTC"]
parsed_starts_at = tz.parse(event_params[:start])
parsed_ends_at = event_params[:end] ? tz.parse(event_params[:end]) : nil
parsed_recurrence_until =
event_params[:"recurrence-until"] ? tz.parse(event_params[:"recurrence-until"]) : nil

params = {
name: event_params[:name],
original_starts_at: parsed_starts_at,
original_ends_at: parsed_ends_at,
url: event_params[:url],
description: event_params[:description],
location: event_params[:location],
recurrence: event_params[:recurrence],
recurrence_until: parsed_recurrence_until,
timezone: event_params[:timezone],
show_local_time: event_params[:"show-local-time"] == "true",
status: Event.statuses[event_params[:status]&.to_sym] || event.status,
reminders: event_params[:reminders],
raw_invitees: event_params[:"allowed-groups"]&.split(","),
minimal: event_params[:minimal],
closed: event_params[:closed] || false,
chat_enabled: event_params[:"chat-enabled"]&.downcase == "true",
}

params[:custom_fields] = {}
SiteSetting
.discourse_post_event_allowed_custom_fields
.split("|")
.each do |setting|
if event_params[setting.to_sym].present?
params[:custom_fields][setting] = event_params[setting.to_sym]
end
end

event.update_with_params!(params)
event.set_topic_bump
elsif post.event
post.event.destroy!
end
end

def missing_users(excluded_ids = self.invitees.select(:user_id))
users = User.real.activated.not_silenced.not_suspended.not_staged

if self.raw_invitees.present?
user_ids =
users
.joins(:groups)
.where("groups.name" => self.raw_invitees)
.where.not(id: excluded_ids)
.select(:id)
User.where(id: user_ids)
else
users.where.not(id: excluded_ids)
end
end

def update_with_params!(params)
case params[:status] ? params[:status].to_i : self.status
when Event.statuses[:private]
if params.key?(:raw_invitees)
params = params.merge(raw_invitees: Array(params[:raw_invitees]) - [PUBLIC_GROUP])
else
params = params.merge(raw_invitees: Array(self.raw_invitees) - [PUBLIC_GROUP])
end
self.update!(params)
self.enforce_private_invitees!
when Event.statuses[:public]
self.update!(params.merge(raw_invitees: [PUBLIC_GROUP]))
when Event.statuses[:standalone]
self.update!(params.merge(raw_invitees: []))
self.invitees.destroy_all
end

self.publish_update!
end

def chat_channel_sync
if self.chat_enabled && self.chat_channel_id.blank? && post.last_editor_id.present?
DiscoursePostEvent::ChatChannelSync.sync(
self,
guardian: Guardian.new(User.find_by(id: post.last_editor_id)),
)
end
end

def calculate_next_date
if self.recurrence.blank? || original_starts_at > Time.current
return { starts_at: original_starts_at, ends_at: original_ends_at, rescheduled: false }
end

next_starts_at =
RRuleGenerator.generate(
starts_at: original_starts_at.in_time_zone(timezone),
timezone:,
recurrence:,
recurrence_until:,
).first

if original_ends_at
difference = original_ends_at - original_starts_at
next_ends_at = next_starts_at + difference.seconds
else
next_ends_at = nil
end

{ starts_at: next_starts_at, ends_at: next_ends_at, rescheduled: true }
end
end
end

# == Schema Information
#
# Table name: discourse_post_event_events
#
# id :bigint not null, primary key
# status :integer default(0), not null
# original_starts_at :datetime not null
# original_ends_at :datetime
# deleted_at :datetime
# raw_invitees :string is an Array
# name :string
# url :string(1000)
# description :string(1000)
# location :string(1000)
# custom_fields :jsonb not null
# reminders :string
# recurrence :string
# timezone :string
# minimal :boolean
# closed :boolean default(FALSE), not null
# chat_enabled :boolean default(FALSE), not null
# chat_channel_id :bigint
# recurrence_until :datetime
# show_local_time :boolean default(FALSE), not null
#

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true

module DiscoursePostEvent
class EventDate < ActiveRecord::Base
self.table_name = "discourse_calendar_post_event_dates"
belongs_to :event

scope :pending,
-> do
where(finished_at: nil).joins(:event).where(
"discourse_post_event_events.deleted_at is NULL",
)
end
scope :expired, -> { where("ends_at IS NOT NULL AND ends_at < ?", Time.now) }
scope :not_expired, -> { where("ends_at IS NULL OR ends_at > ?", Time.now) }

after_commit :upsert_topic_custom_field, on: %i[create]
def upsert_topic_custom_field
if self.event.post && self.event.post.is_first_post?
TopicCustomField.upsert(
{
topic_id: self.event.post.topic_id,
name: TOPIC_POST_EVENT_STARTS_AT,
value: self.starts_at,
created_at: Time.now,
updated_at: Time.now,
},
unique_by: "idx_topic_custom_fields_topic_post_event_starts_at",
)

TopicCustomField.upsert(
{
topic_id: self.event.post.topic_id,
name: TOPIC_POST_EVENT_ENDS_AT,
value: self.ends_at,
created_at: Time.now,
updated_at: Time.now,
},
unique_by: "idx_topic_custom_fields_topic_post_event_ends_at",
)
end
end

def started?
starts_at <= Time.current
end

def ended?
(ends_at || starts_at.end_of_day) <= Time.current
end
end
end

# == Schema Information
#
# Table name: discourse_calendar_post_event_dates
#
# id :bigint not null, primary key
# event_id :integer
# starts_at :datetime
# ends_at :datetime
# reminder_counter :integer default(0)
# event_will_start_sent_at :datetime
# event_started_sent_at :datetime
# finished_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_discourse_calendar_post_event_dates_on_event_id (event_id)
# index_discourse_calendar_post_event_dates_on_finished_at (finished_at)
#

View file

@ -0,0 +1,90 @@
# frozen_string_literal: true

module DiscoursePostEvent
class Invitee < ActiveRecord::Base
UNKNOWN_ATTENDANCE = "unknown"

self.table_name = "discourse_post_event_invitees"

belongs_to :event, foreign_key: :post_id
belongs_to :user

default_scope { joins(:user).includes(:user).where("users.id IS NOT NULL") }
scope :with_status, ->(status) { where(status: Invitee.statuses[status]) }

after_commit :sync_chat_channel_members

def self.statuses
@statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
end

def self.create_attendance!(user_id, post_id, status)
invitee =
Invitee.create!(status: Invitee.statuses[status.to_sym], post_id: post_id, user_id: user_id)
invitee.event.publish_update!
invitee.update_topic_tracking!
DiscourseEvent.trigger(:discourse_calendar_post_event_invitee_status_changed, invitee)
invitee
rescue ActiveRecord::RecordNotUnique
# do nothing in case multiple new attendances would be created very fast
Invitee.find_by(post_id: post_id, user_id: user_id)
end

def update_attendance!(status)
new_status = Invitee.statuses[status.to_sym]
status_changed = self.status != new_status
self.update(status: new_status)
self.event.publish_update!
self.update_topic_tracking! if status_changed
DiscourseEvent.trigger(:discourse_calendar_post_event_invitee_status_changed, self)
self
end

def self.extract_uniq_usernames(groups)
User.real.where(
id: GroupUser.where(group_id: Group.where(name: groups).select(:id)).select(:user_id),
)
end

def sync_chat_channel_members
return if !self.event.chat_enabled?
ChatChannelSync.sync(self.event)
end

def update_topic_tracking!
topic_id = self.event.post.topic.id
user_id = self.user.id
tracking = :regular

case self.status
when Invitee.statuses[:going]
tracking = :watching
when Invitee.statuses[:interested]
tracking = :tracking
end

TopicUser.change(
user_id,
topic_id,
notification_level: TopicUser.notification_levels[tracking],
)
end
end
end

# == Schema Information
#
# Table name: discourse_post_event_invitees
#
# id :bigint not null, primary key
# post_id :integer not null
# user_id :integer not null
# status :integer
# created_at :datetime not null
# updated_at :datetime not null
# notified :boolean default(FALSE), not null
#
# Indexes
#
# discourse_post_event_invitees_post_id_user_id_idx (post_id,user_id) UNIQUE
#

View file

@ -0,0 +1,156 @@
# frozen_string_literal: true

module DiscoursePostEvent
class EventSerializer < ApplicationSerializer
attributes :can_act_on_discourse_post_event
attributes :can_update_attendance
attributes :category_id
attributes :creator
attributes :custom_fields
attributes :ends_at
attributes :id
attributes :is_closed
attributes :is_expired
attributes :is_ongoing
attributes :is_private
attributes :is_public
attributes :is_standalone
attributes :minimal
attributes :name
attributes :post
attributes :raw_invitees
attributes :recurrence
attributes :recurrence_rule
attributes :recurrence_until
attributes :reminders
attributes :sample_invitees
attributes :should_display_invitees
attributes :starts_at
attributes :stats
attributes :status
attributes :timezone
attributes :show_local_time
attributes :url
attributes :description
attributes :location
attributes :watching_invitee
attributes :chat_enabled
attributes :channel

def channel
::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
end

def include_channel?
object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present?
end

def can_act_on_discourse_post_event
scope.can_act_on_discourse_post_event?(object)
end

def reminders
(object.reminders || "")
.split(",")
.map do |reminder|
unit, value, type = reminder.split(".").reverse
type ||= "notification"

value = value.to_i
{ value: value.to_i.abs, unit: unit, period: value > 0 ? "before" : "after", type: type }
end
end

def is_expired
object.expired?
end

def is_ongoing
object.ongoing?
end

def is_public
object.public?
end

def is_private
object.private?
end

def is_standalone
object.standalone?
end

def is_closed
object.closed
end

def status
Event.statuses[object.status]
end

# lightweight post object containing
# only needed info for client
def post
{
id: object.post.id,
post_number: object.post.post_number,
url: object.post.url,
topic: {
id: object.post.topic.id,
title: object.post.topic.title,
},
}
end

def can_update_attendance
scope.current_user && object.can_user_update_attendance(scope.current_user)
end

def creator
BasicUserSerializer.new(object.post.user, embed: :objects, root: false)
end

def stats
EventStatsSerializer.new(object, root: false).as_json
end

def watching_invitee
if scope.current_user
watching_invitee = Invitee.find_by(user_id: scope.current_user.id, post_id: object.id)
end

InviteeSerializer.new(watching_invitee, root: false) if watching_invitee
end

def sample_invitees
invitees = object.most_likely_going
ActiveModel::ArraySerializer.new(invitees, each_serializer: InviteeSerializer)
end

def should_display_invitees
(object.public? && object.invitees.count > 0) ||
(object.private? && object.raw_invitees.count > 0)
end

def category_id
object.post.topic.category_id
end

def include_url?
object.url.present?
end

def include_recurrence_rule?
object.recurring?
end

def recurrence_rule
RRuleConfigurator.rule(
recurrence: object.recurrence,
starts_at: object.starts_at.in_time_zone(object.timezone),
recurrence_until: object.recurrence_until&.in_time_zone(object.timezone),
)
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true

module DiscoursePostEvent
class EventStatsSerializer < ApplicationSerializer
attributes :going
attributes :interested
attributes :not_going
attributes :invited

def invited
unanswered = counts[nil] || 0

# when a group is private we know the list of possible users
# even if an invitee has not been created yet
unanswered += object.missing_users.count if object.private?

going + interested + not_going + unanswered
end

def going
@going ||= counts[Invitee.statuses[:going]] || 0
end

def interested
@interested ||= counts[Invitee.statuses[:interested]] || 0
end

def not_going
@not_going ||= counts[Invitee.statuses[:not_going]] || 0
end

def counts
@counts ||= object.invitees.group(:status).count
end
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true

module DiscoursePostEvent
class EventSummarySerializer < ApplicationSerializer
attributes :id
attributes :starts_at
attributes :ends_at
attributes :show_local_time
attributes :timezone
attributes :post
attributes :name
attributes :category_id
attributes :upcoming_dates

# lightweight post object containing
# only needed info for client
def post
post_hash = {
id: object.post.id,
post_number: object.post.post_number,
url: object.post.url,
topic: {
id: object.post.topic.id,
title: object.post.topic.title,
},
}

if post_hash[:topic][:title].match?(/:[\w\-+]+:/)
post_hash[:topic][:title] = Emoji.gsub_emoji_to_unicode(post_hash[:topic][:title])
end

if JSON.parse(SiteSetting.map_events_to_color).size > 0
post_hash[:topic][:category_slug] = object.post.topic&.category&.slug
post_hash[:topic][:tags] = object.post.topic.tags&.map(&:name)
end

post_hash
end

def category_id
object.post.topic.category_id
end

def include_upcoming_dates?
object.recurring?
end

def upcoming_dates
difference = object.original_ends_at ? object.original_ends_at - object.original_starts_at : 0

RRuleGenerator
.generate(
starts_at: object.original_starts_at.in_time_zone(object.timezone),
timezone: object.timezone,
max_years: 1,
recurrence: object.recurrence,
recurrence_until: object.recurrence_until,
)
.map { |date| { starts_at: date, ends_at: date + difference.seconds } }
end
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true

module DiscoursePostEvent
class InviteeListSerializer < ApplicationSerializer
root false
attributes :meta
has_many :invitees, serializer: InviteeSerializer, embed: :objects

def invitees
object[:invitees]
end

def meta
{
suggested_users:
ActiveModel::ArraySerializer.new(
suggested_users,
each_serializer: BasicUserSerializer,
scope: scope,
),
}
end

def include_meta?
suggested_users.present?
end

def suggested_users
object[:suggested_users]
end
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true

module DiscoursePostEvent
class InviteeSerializer < ApplicationSerializer
attributes :id, :status, :user, :post_id, :meta

def status
object.status ? Invitee.statuses[object.status] : nil
end

def include_id?
object.id
end

def user
BasicUserSerializer.new(object.user, embed: :objects, root: false)
end

def meta
{
event_should_display_invitees:
(object.event.public? && object.event.invitees.count > 0) ||
(object.event.private? && object.event.raw_invitees.count > 0),
event_stats: EventStatsSerializer.new(object.event, root: false),
}
end
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true

class UserTimezoneSerializer < BasicUserSerializer
attributes :timezone, :on_holiday

def on_holiday
@options[:on_holiday] || false
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true

require "holidays"

module DiscourseCalendar
class Holiday
def self.find_holidays_for(
region_code:,
start_date: Date.current.beginning_of_year,
end_date: Date.current.end_of_year,
show_holiday_observed_on_dates: false
)
holidays =
Holidays.between(
start_date,
end_date,
[region_code],
show_holiday_observed_on_dates ? :observed : [],
)

holidays.map do |holiday|
holiday[:disabled] = DiscourseCalendar::DisabledHoliday.where(
region_code: region_code,
).exists?(holiday_name: holiday[:name])
end

holidays
end
end
end

View file

@ -0,0 +1,69 @@
# frozen_string_literal: true
#
module DiscoursePostEvent
class ChatChannelSync
def self.sync(event, guardian: nil)
return if !event.chat_enabled?
if !event.chat_channel_id && guardian&.can_create_chat_channel?
ensure_chat_channel!(event, guardian:)
end
sync_chat_channel_members!(event) if event.chat_channel_id
end

def self.sync_chat_channel_members!(event)
missing_members_sql = <<~SQL
SELECT user_id
FROM discourse_post_event_invitees
WHERE post_id = :post_id
AND status in (:statuses)
AND user_id NOT IN (
SELECT user_id
FROM user_chat_channel_memberships
WHERE chat_channel_id = :chat_channel_id
)
SQL

missing_user_ids =
DB.query_single(
missing_members_sql,
post_id: event.post.id,
statuses: [
DiscoursePostEvent::Invitee.statuses[:going],
DiscoursePostEvent::Invitee.statuses[:interested],
],
chat_channel_id: event.chat_channel_id,
)

if missing_user_ids.present?
ActiveRecord::Base.transaction do
missing_user_ids.each do |user_id|
event.chat_channel.user_chat_channel_memberships.create!(
user_id:,
chat_channel_id: event.chat_channel_id,
following: true,
)
end
end
end
end

def self.ensure_chat_channel!(event, guardian:)
name = event.name

channel = nil
Chat::CreateCategoryChannel.call(
guardian:,
params: {
name:,
category_id: event.post.topic.category_id,
},
) do |result|
on_success { channel = result.channel }
on_failure { raise StandardError, result.inspect_steps }
end

# event creator will be a member of the channel
event.chat_channel_id = channel.id
end
end
end

View file

@ -0,0 +1,7 @@
import RestAdapter from "discourse/adapters/rest";

export default class DiscoursePostEventAdapter extends RestAdapter {
basePath() {
return "/discourse-post-event/";
}
}

View file

@ -0,0 +1,15 @@
import { underscore } from "@ember/string";
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";

export default class DiscoursePostEventEvent extends DiscoursePostEventAdapter {
pathFor(store, type, findArgs) {
const path =
this.basePath(store, type, findArgs) +
underscore(store.pluralize(this.apiNameFor(type)));
return this.appendQueryParams(path, findArgs);
}

apiNameFor() {
return "event";
}
}

View file

@ -0,0 +1,7 @@
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";

export default class DiscoursePostEventInvitee extends DiscoursePostEventNestedAdapter {
apiNameFor() {
return "invitee";
}
}

View file

@ -0,0 +1,65 @@
import { underscore } from "@ember/string";
import { Result } from "discourse/adapters/rest";
import { ajax } from "discourse/lib/ajax";
import DiscoursePostEventAdapter from "./discourse-post-event-adapter";

export default class DiscoursePostEventNestedAdapter extends DiscoursePostEventAdapter {
// TODO: destroy/update/create should be improved in core to allow for nested models
destroyRecord(store, type, record) {
return ajax(
this.pathFor(store, type, {
post_id: record.post_id,
id: record.id,
}),
{
type: "DELETE",
}
);
}

update(store, type, id, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;

return ajax(
this.pathFor(store, type, { id, post_id: attrs.post_id }),
this.getPayload("PUT", data)
).then(function (json) {
return new Result(json[typeField], json);
});
}

createRecord(store, type, attrs) {
const data = {};
const typeField = underscore(this.apiNameFor(type));
data[typeField] = attrs;
return ajax(
this.pathFor(store, type, attrs),
this.getPayload("POST", data)
).then(function (json) {
return new Result(json[typeField], json);
});
}

pathFor(store, type, findArgs) {
const post_id = findArgs["post_id"];
delete findArgs["post_id"];

const id = findArgs["id"];
delete findArgs["id"];

let path =
this.basePath(store, type, {}) +
"events/" +
post_id +
"/" +
underscore(store.pluralize(this.apiNameFor()));

if (id) {
path += `/${id}`;
}

return this.appendQueryParams(path, findArgs);
}
}

View file

@ -0,0 +1,7 @@
import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter";

export default class DiscoursePostEventReminder extends DiscoursePostEventNestedAdapter {
apiNameFor() {
return "reminder";
}
}

View file

@ -0,0 +1,7 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("calendar");
},
};

View file

@ -0,0 +1,35 @@
import { apiInitializer } from "discourse/lib/api";
import GroupTimezones from "../components/group-timezones";

const GroupTimezonesShim = <template>
<GroupTimezones
@members={{@data.members}}
@group={{@data.group}}
@size={{@data.size}}
/>
</template>;

export default apiInitializer((api) => {
api.decorateCookedElement((element, helper) => {
element.querySelectorAll(".group-timezones").forEach((el) => {
const post = helper.getModel();

if (!post) {
return;
}

const group = el.dataset.group;
if (!group) {
throw new Error(
"Group timezone element is missing 'data-group' attribute"
);
}

helper.renderGlimmer(el, GroupTimezonesShim, {
group,
members: (post.group_timezones || {})[group] || [],
size: el.dataset.size || "medium",
});
});
});
});

View file

@ -0,0 +1,69 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { classNameBindings, tagName } from "@ember-decorators/component";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";

@tagName("tr")
@classNameBindings("isHolidayDisabled:disabled")
export default class AdminHolidaysListItem extends Component {
loading = false;
isHolidayDisabled = false;

@action
disableHoliday(holiday, region_code) {
if (this.loading) {
return;
}

this.set("loading", true);

return ajax({
url: `/admin/discourse-calendar/holidays/disable`,
type: "POST",
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
})
.then(() => this.set("isHolidayDisabled", true))
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
}

@action
enableHoliday(holiday, region_code) {
if (this.loading) {
return;
}

this.set("loading", true);

return ajax({
url: `/admin/discourse-calendar/holidays/enable`,
type: "DELETE",
data: { disabled_holiday: { holiday_name: holiday.name, region_code } },
})
.then(() => this.set("isHolidayDisabled", false))
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
}

<template>
<td>{{this.holiday.date}}</td>
<td>{{this.holiday.name}}</td>
<td>
{{#if this.isHolidayDisabled}}
<DButton
{{! template-lint-disable no-action }}
@action={{action "enableHoliday" this.holiday this.region_code}}
@label="discourse_calendar.enable_holiday"
/>
{{else}}
<DButton
{{! template-lint-disable no-action }}
@action={{action "disableHoliday" this.holiday this.region_code}}
@label="discourse_calendar.disable_holiday"
/>
{{/if}}
</td>
</template>
}

View file

@ -0,0 +1,25 @@
import { i18n } from "discourse-i18n";
import AdminHolidaysListItem from "./admin-holidays-list-item";

const AdminHolidaysList = <template>
<table class="holidays-list">
<thead>
<tr>
<td>{{i18n "discourse_calendar.date"}}</td>
<td colspan="2">{{i18n "discourse_calendar.holiday"}}</td>
</tr>
</thead>

<tbody>
{{#each @holidays as |holiday|}}
<AdminHolidaysListItem
@holiday={{holiday}}
@isHolidayDisabled={{holiday.disabled}}
@region_code={{@region_code}}
/>
{{/each}}
</tbody>
</table>
</template>;

export default AdminHolidaysList;

View file

@ -0,0 +1,36 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";

export default class BulkInviteSampleCsvFile extends Component {
@action
downloadSampleCsv() {
const sampleData = [
["my_awesome_group", "going"],
["lucy", "interested"],
["mark", "not_going"],
["sam", "unknown"],
];

let csv = "";
sampleData.forEach((row) => {
csv += row.join(",");
csv += "\n";
});

const btn = document.createElement("a");
btn.href = `data:text/csv;charset=utf-8,${encodeURI(csv)}`;
btn.target = "_blank";
btn.rel = "noopener noreferrer";
btn.download = "bulk-invite-sample.csv";
btn.click();
}

<template>
<DButton
@label="discourse_post_event.bulk_invite_modal.download_sample_csv"
{{! template-lint-disable no-action }}
@action={{action "downloadSampleCsv"}}
/>
</template>
}

View file

@ -0,0 +1,64 @@
import Component from "@glimmer/component";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { or } from "truth-helpers";
import icon from "discourse/helpers/d-icon";
import UppyUpload from "discourse/lib/uppy/uppy-upload";
import { i18n } from "discourse-i18n";

export default class CsvUploader extends Component {
@service dialog;

uppyUpload = new UppyUpload(getOwner(this), {
type: "csv",
id: "discourse-post-event-csv-uploader",
autoStartUploads: false,
uploadUrl: this.args.uploadUrl,
uppyReady: () => {
this.uppyUpload.uppyWrapper.uppyInstance.on("file-added", () => {
this.dialog.confirm({
message: i18n(`${this.args.i18nPrefix}.confirmation_message`),
didConfirm: () => this.uppyUpload.startUpload(),
didCancel: () => this.uppyUpload.reset(),
});
});
},
uploadDone: () => {
this.dialog.alert(i18n(`${this.args.i18nPrefix}.success`));
},
validateUploadedFilesOptions: {
csvOnly: true,
},
});

get uploadButtonText() {
return this.uppyUpload.uploading
? i18n("uploading")
: i18n(`${this.args.i18nPrefix}.text`);
}

get uploadButtonDisabled() {
// https://github.com/emberjs/ember.js/issues/10976#issuecomment-132417731
return this.uppyUpload.uploading || this.uppyUpload.processing || null;
}

<template>
<span>
<label class="btn" disabled={{this.uploadButtonDisabled}}>
{{icon "upload"}}&nbsp;{{this.uploadButtonText}}
<input
{{didInsert this.uppyUpload.setup}}
class="hidden-upload-field"
disabled={{this.uppyUpload.uploading}}
type="file"
accept=".csv"
/>
</label>
{{#if (or this.uppyUpload.uploading this.uppyUpload.processing)}}
<span>{{i18n "upload_selector.uploading"}}
{{this.uppyUpload.uploadProgress}}%</span>
{{/if}}
</span>
</template>
}

View file

@ -0,0 +1,20 @@
import { LinkTo } from "@ember/routing";
import { and } from "truth-helpers";
import { optionalRequire } from "discourse/lib/utilities";

const ChannelTitle = optionalRequire(
"discourse/plugins/chat/discourse/components/channel-title"
);

const DiscoursePostEventChatChannel = <template>
{{#if (and @event.channel ChannelTitle)}}
<section class="event__section event-chat-channel">
<span></span>
<LinkTo @route="chat.channel" @models={{@event.channel.routeModels}}>
<ChannelTitle @channel={{@event.channel}} />
</LinkTo>
</section>
{{/if}}
</template>;

export default DiscoursePostEventChatChannel;

View file

@ -0,0 +1,23 @@
import Component from "@glimmer/component";
import avatar from "discourse/helpers/avatar";
import { formatUsername } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";

export default class DiscoursePostEventCreator extends Component {
get username() {
return this.args.user.name || formatUsername(this.args.user.username);
}

<template>
<span class="creators">
<span class="created-by">{{i18n "discourse_post_event.created_by"}}</span>

<span class="event-creator">
<a class="topic-invitee-avatar" data-user-card={{@user.username}}>
{{avatar @user imageSize="tiny"}}
<span class="username">{{this.username}}</span>
</a>
</span>
</span>
</template>
}

View file

@ -0,0 +1,154 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
import { applyLocalDates } from "discourse/lib/local-dates";
import { cook } from "discourse/lib/text";

export default class DiscoursePostEventDates extends Component {
@service siteSettings;

@tracked htmlDates = "";

get startsAt() {
return moment(this.args.event.startsAt).tz(this.timezone);
}

get endsAt() {
return (
this.args.event.endsAt && moment(this.args.event.endsAt).tz(this.timezone)
);
}

get timezone() {
return this.args.event.timezone || "UTC";
}

get startsAtFormat() {
return this._buildFormat(this.startsAt, {
includeYear: !this.isSameYear(this.startsAt),
includeTime: this.hasTime(this.startsAt) || this.isSingleDayEvent,
});
}

get endsAtFormat() {
if (this.isSingleDayEvent) {
return "LT";
}

return this._buildFormat(this.endsAt, {
includeYear:
!this.isSameYear(this.endsAt) ||
!this.isSameYear(this.endsAt, this.startsAt),
includeTime: this.hasTime(this.endsAt),
});
}

_buildFormat(date, { includeYear, includeTime }) {
const formatParts = ["ddd, MMM D"];
if (includeYear) {
formatParts.push("YYYY");
}

const dateString = formatParts.join(", ");
const timeString = includeTime ? " LT" : "";

return `\u0022${dateString}${timeString}\u0022`;
}

get isSingleDayEvent() {
return this.startsAt.isSame(this.endsAt, "day");
}

get datesBBCode() {
const dates = [];

dates.push(
this.buildDateBBCode({
date: this.startsAt,
format: this.startsAtFormat,
range: !!this.endsAt && "from",
})
);

if (this.endsAt) {
dates.push(
this.buildDateBBCode({
date: this.endsAt,
format: this.endsAtFormat,
range: "to",
})
);
}

return dates;
}

isSameYear(date1, date2) {
return date1.isSame(date2 || moment(), "year");
}

hasTime(date) {
return date.hour() || date.minute();
}

buildDateBBCode({ date, format, range }) {
const bbcode = {
date: date.format("YYYY-MM-DD"),
time: date.format("HH:mm"),
format,
timezone: this.timezone,
hideTimezone: this.args.event.showLocalTime,
};

if (this.args.event.showLocalTime) {
bbcode.displayedTimezone = this.args.event.timezone;
}

if (range) {
bbcode.range = range;
}

const content = Object.entries(bbcode)
.map(([key, value]) => `${key}=${value}`)
.join(" ");

return `[${content}]`;
}

@action
async computeDates(element) {
if (this.siteSettings.discourse_local_dates_enabled) {
const result = await cook(this.datesBBCode.join("<span> → </span>"));
this.htmlDates = htmlSafe(result.toString());

next(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}

applyLocalDates(
element.querySelectorAll(
`[data-post-id="${this.args.event.id}"] .discourse-local-date`
),
this.siteSettings
);
});
} else {
let dates = `${this.startsAt.format(this.startsAtFormat)}`;
if (this.endsAt) {
dates += ` → ${moment(this.endsAt).format(this.endsAtFormat)}`;
}
this.htmlDates = htmlSafe(dates);
}
}

<template>
<section class="event__section event-dates" {{didInsert this.computeDates}}>
{{icon "clock"}}{{this.htmlDates}}</section>
</template>
}

View file

@ -0,0 +1,11 @@
import CookText from "discourse/components/cook-text";

const DiscoursePostEventDescription = <template>
{{#if @description}}
<section class="event__section event-description">
<CookText @rawText={{@description}} />
</section>
{{/if}}
</template>;

export default DiscoursePostEventDescription;

View file

@ -0,0 +1,36 @@
import Component from "@glimmer/component";
import { i18n } from "discourse-i18n";

export default class EventStatus extends Component {
get eventStatusLabel() {
return i18n(
`discourse_post_event.models.event.status.${this.args.event.status}.title`
);
}

get eventStatusDescription() {
return i18n(
`discourse_post_event.models.event.status.${this.args.event.status}.description`
);
}

get statusClass() {
return `status ${this.args.event.status}`;
}

<template>
{{#if @event.isExpired}}
<span class="status expired">
{{i18n "discourse_post_event.models.event.expired"}}
</span>
{{else if @event.isClosed}}
<span class="status closed">
{{i18n "discourse_post_event.models.event.closed"}}
</span>
{{else}}
<span class={{this.statusClass}} title={{this.eventStatusDescription}}>
{{this.eventStatusLabel}}
</span>
{{/if}}
</template>
}

View file

@ -0,0 +1,157 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import lazyHash from "discourse/helpers/lazy-hash";
import replaceEmoji from "discourse/helpers/replace-emoji";
import routeAction from "discourse/helpers/route-action";
import ChatChannel from "./chat-channel";
import Creator from "./creator";
import Dates from "./dates";
import Description from "./description";
import EventStatus from "./event-status";
import Invitees from "./invitees";
import Location from "./location";
import MoreMenu from "./more-menu";
import Status from "./status";
import Url from "./url";

const StatusSeparator = <template>
<span class="separator">·</span>
</template>;

const InfoSection = <template>
<section class="event__section" ...attributes>
{{#if @icon}}
{{icon @icon}}
{{/if}}

{{yield}}
</section>
</template>;

export default class DiscoursePostEvent extends Component {
@service currentUser;
@service discoursePostEventApi;
@service messageBus;

setupMessageBus = modifier(() => {
const { event } = this.args;
const path = `/discourse-post-event/${event.post.topic.id}`;
this.messageBus.subscribe(path, async (msg) => {
const eventData = await this.discoursePostEventApi.event(msg.id);
event.updateFromEvent(eventData);
});

return () => this.messageBus.unsubscribe(path);
});

get localStartsAtTime() {
let time = moment(this.args.event.startsAt);
if (this.args.event.showLocalTime && this.args.event.timezone) {
time = time.tz(this.args.event.timezone);
}
return time;
}

get startsAtMonth() {
return this.localStartsAtTime.format("MMM");
}

get startsAtDay() {
return this.localStartsAtTime.format("D");
}

get eventName() {
return this.args.event.name || this.args.event.post.topic.title;
}

get isPublicEvent() {
return this.args.event.status === "public";
}

get isStandaloneEvent() {
return this.args.event.status === "standalone";
}

get canActOnEvent() {
return this.currentUser && this.args.event.can_act_on_discourse_post_event;
}

get watchingInviteeStatus() {
return this.args.event.watchingInvitee?.status;
}

<template>
<div
class={{concatClass
"discourse-post-event"
(if @event "is-loaded" "is-loading")
}}
>
<div class="discourse-post-event-widget">
{{#if @event}}
<header class="event-header" {{this.setupMessageBus}}>
<div class="event-date">
<div class="month">{{this.startsAtMonth}}</div>
<div class="day">{{this.startsAtDay}}</div>
</div>
<div class="event-info">
<span class="name">
{{replaceEmoji this.eventName}}
</span>
<div class="status-and-creators">
<PluginOutlet
@name="discourse-post-event-status-and-creators"
@outletArgs={{lazyHash
event=@event
Separator=StatusSeparator
Status=(component EventStatus event=@event)
Creator=(component Creator user=@event.creator)
}}
>
<EventStatus @event={{@event}} />
<StatusSeparator />
<Creator @user={{@event.creator}} />
</PluginOutlet>
</div>
</div>

<MoreMenu
@event={{@event}}
@isStandaloneEvent={{this.isStandaloneEvent}}
@composePrivateMessage={{routeAction "composePrivateMessage"}}
/>
</header>

<PluginOutlet
@name="discourse-post-event-info"
@outletArgs={{lazyHash
event=@event
Section=(component InfoSection event=@event)
Url=(component Url url=@event.url)
Description=(component Description description=@event.description)
Location=(component Location location=@event.location)
Dates=(component Dates event=@event)
Invitees=(component Invitees event=@event)
Status=(component Status event=@event)
ChatChannel=(component ChatChannel event=@event)
}}
>
<Dates @event={{@event}} />
<Location @location={{@event.location}} />
<Url @url={{@event.url}} />
<ChatChannel @event={{@event}} />
<Invitees @event={{@event}} />
<Description @description={{@event.description}} />
{{#if @event.canUpdateAttendance}}
<Status @event={{@event}} />
{{/if}}
</PluginOutlet>
{{/if}}
</div>
</div>
</template>
}

View file

@ -0,0 +1,53 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import AvatarFlair from "discourse/components/avatar-flair";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";

export default class DiscoursePostEventInvitee extends Component {
@service site;
@service currentUser;

get statusIcon() {
switch (this.args.invitee.status) {
case "going":
return "check";
case "interested":
return "star";
case "not_going":
return "xmark";
}
}

get flairName() {
const string = `discourse_post_event.models.invitee.status.${this.args.invitee.status}`;

return i18n(string);
}

<template>
<li
class={{concatClass
"event-invitee"
(if @invitee.status (concat "status-" @invitee.status))
(if (eq this.currentUser.id @invitee.user.id) "is-current-user")
}}
>
<a class="topic-invitee-avatar" data-user-card={{@invitee.user.username}}>
{{avatar
@invitee.user
imageSize=(if this.site.mobileView "tiny" "large")
}}
{{#if this.statusIcon}}
<AvatarFlair
@flairName={{this.flairName}}
@flairUrl={{this.statusIcon}}
/>
{{/if}}
</a>
</li>
</template>
}

View file

@ -0,0 +1,55 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import Invitee from "./invitee";

export default class DiscoursePostEventInvitees extends Component {
@service modal;
@service siteSettings;

get hasAttendees() {
return this.args.event.stats.going > 0;
}

get statsInfo() {
return this.args.event.stats.going;
}

get inviteesTitle() {
return i18n("discourse_post_event.models.invitee.status.going_count", {
count: this.args.event.stats.going,
});
}

<template>
{{#unless @event.minimal}}
{{#if @event.shouldDisplayInvitees}}
<section class="event__section event-invitees">
<div class="event-invitees-avatars-container">
<div class="event-invitees-icon" title={{this.inviteesTitle}}>
{{icon "users"}}
{{#if this.hasAttendees}}
<span class="going">{{this.statsInfo}}</span>
{{/if}}
</div>
<ul class="event-invitees-avatars">
{{#each @event.sampleInvitees as |invitee|}}
<Invitee @invitee={{invitee}} />
{{/each}}
</ul>
</div>
</section>
{{else}}
{{#unless @event.isStandalone}}
<section class="event__section event-invitees no-rsvp">
<p class="no-rsvp-description">{{i18n
"discourse_post_event.models.invitee.status.going_count.other"
count="0"
}}</p>
</section>
{{/unless}}
{{/if}}
{{/unless}}
</template>
}

View file

@ -0,0 +1,14 @@
import CookText from "discourse/components/cook-text";
import icon from "discourse/helpers/d-icon";

const DiscoursePostEventLocation = <template>
{{#if @location}}
<section class="event__section event-location">
{{icon "location-pin"}}

<CookText @rawText={{@location}} />
</section>
{{/if}}
</template>;

export default DiscoursePostEventLocation;

View file

@ -0,0 +1,382 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import EmberObject, { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { downloadCalendar } from "discourse/lib/download-calendar";
import { exportEntity } from "discourse/lib/export-csv";
import { getAbsoluteURL } from "discourse/lib/get-url";
import { cook } from "discourse/lib/text";
import { applyValueTransformer } from "discourse/lib/transformer";
import { i18n } from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
import PostEventBuilder from "../modal/post-event-builder";
import PostEventBulkInvite from "../modal/post-event-bulk-invite";
import PostEventInviteUserOrGroup from "../modal/post-event-invite-user-or-group";
import PostEventInvitees from "../modal/post-event-invitees";

export default class DiscoursePostEventMoreMenu extends Component {
@service currentUser;
@service dialog;
@service discoursePostEventApi;
@service modal;
@service router;
@service siteSettings;
@service store;

@tracked isSavingEvent = false;

get expiredOrClosed() {
return this.args.event.isExpired || this.args.event.isClosed;
}

get canActOnEvent() {
return this.currentUser && this.args.event.canActOnDiscoursePostEvent;
}

get shouldShowParticipants() {
return applyValueTransformer(
"discourse-calendar-event-more-menu-should-show-participants",
this.canActOnEvent && !this.args.isStandaloneEvent,
{
event: this.args.event,
}
);
}

get canInvite() {
return (
!this.expiredOrClosed && this.canActOnEvent && this.args.event.isPublic
);
}

get canSeeUpcomingEvents() {
return !this.args.event.isClosed && this.args.event.recurrence;
}

get canBulkInvite() {
return !this.expiredOrClosed && !this.args.event.isStandalone;
}

get canSendPmToCreator() {
return (
this.currentUser &&
this.currentUser.username !== this.args.event.creator.username
);
}

@action
addToCalendar() {
this.menuApi.close();

const event = this.args.event;

downloadCalendar(
event.name || event.post.topic.title,
[
{
startsAt: event.startsAt,
endsAt: event.endsAt,
},
],
{
recurrenceRule: event.recurrenceRule,
location: event.url,
details: getAbsoluteURL(event.post.url),
}
);
}

@action
sendPMToCreator() {
this.menuApi.close();

this.args.composePrivateMessage(
EmberObject.create(this.args.event.creator),
EmberObject.create(this.args.event.post)
);
}

@action
upcomingEvents() {
this.router.transitionTo("discourse-post-event-upcoming-events");
}

@action
registerMenuApi(api) {
this.menuApi = api;
}

@action
async inviteUserOrGroup() {
this.menuApi.close();

try {
this.modal.show(PostEventInviteUserOrGroup, {
model: { event: this.args.event },
});
} catch (e) {
popupAjaxError(e);
}
}

@action
exportPostEvent() {
this.menuApi.close();

exportEntity("post_event", {
name: "post_event",
id: this.args.event.id,
});
}

@action
bulkInvite() {
this.menuApi.close();

this.modal.show(PostEventBulkInvite, {
model: { event: this.args.event },
});
}

@action
async openEvent() {
this.menuApi.close();

this.dialog.yesNoConfirm({
message: i18n("discourse_post_event.builder_modal.confirm_open"),
didConfirm: async () => {
this.isSavingEvent = true;

try {
const post = await this.store.find("post", this.args.event.id);
this.args.event.isClosed = false;

const eventParams = buildParams(
this.args.event.startsAt,
this.args.event.endsAt,
this.args.event,
this.siteSettings
);

const newRaw = replaceRaw(eventParams, post.raw);

if (newRaw) {
const props = {
raw: newRaw,
edit_reason: i18n("discourse_post_event.edit_reason_opened"),
};

const cooked = await cook(newRaw);
props.cooked = cooked.string;
await post.save(props);
}
} catch (e) {
popupAjaxError(e);
} finally {
this.isSavingEvent = false;
}
},
});
}

@action
async editPostEvent() {
this.menuApi.close();

this.modal.show(PostEventBuilder, {
model: {
event: this.args.event,
},
});
}

@action
showParticipants() {
this.menuApi.close();

this.modal.show(PostEventInvitees, {
model: {
event: this.args.event,
title: this.args.event.title,
extraClass: this.args.event.extraClass,
},
});
}

@action
async closeEvent() {
this.menuApi.close();

this.dialog.yesNoConfirm({
message: i18n("discourse_post_event.builder_modal.confirm_close"),
didConfirm: () => {
this.isSavingEvent = true;
return this.store.find("post", this.args.event.id).then((post) => {
this.args.event.isClosed = true;

const eventParams = buildParams(
this.args.event.startsAt,
this.args.event.endsAt,
this.args.event,
this.siteSettings
);

const newRaw = replaceRaw(eventParams, post.raw);

if (newRaw) {
const props = {
raw: newRaw,
edit_reason: i18n("discourse_post_event.edit_reason_closed"),
};

return cook(newRaw)
.then((cooked) => {
props.cooked = cooked.string;
return post.save(props);
})
.finally(() => {
this.isSavingEvent = false;
});
}
});
},
});
}

<template>
<DMenu
@identifier="discourse-post-event-more-menu"
@triggerClass={{concatClass
"more-dropdown"
(if this.isSavingEvent "--saving")
}}
@icon="ellipsis"
@onRegisterApi={{this.registerMenuApi}}
>
<:content>
<DropdownMenu as |dropdown|>
{{#unless this.expiredOrClosed}}
<dropdown.item class="add-to-calendar">
<DButton
@icon="file"
@label="discourse_post_event.add_to_calendar"
@action={{this.addToCalendar}}
/>
</dropdown.item>
{{/unless}}

{{#if this.canSendPmToCreator}}
<dropdown.item class="send-pm-to-creator">
<DButton
@icon="envelope"
class="btn-transparent"
@translatedLabel={{i18n
"discourse_post_event.send_pm_to_creator"
(hash username=@event.creator.username)
}}
@action={{this.sendPMToCreator}}
/>
</dropdown.item>
{{/if}}

{{#if this.canInvite}}
<dropdown.item class="invite-user-or-group">
<DButton
@icon="user-plus"
class="btn-transparent"
@translatedLabel={{i18n "discourse_post_event.invite"}}
@action={{this.inviteUserOrGroup}}
/>
</dropdown.item>
{{/if}}

{{#if this.canSeeUpcomingEvents}}
<dropdown.item class="upcoming-events">
<DButton
@icon="far-calendar-plus"
class="btn-transparent"
@translatedLabel={{i18n
"discourse_post_event.upcoming_events.title"
}}
@action={{this.upcomingEvents}}
/>
</dropdown.item>
{{/if}}

{{#if this.shouldShowParticipants}}
<dropdown.item class="show-all-participants">
<DButton
@icon="user-group"
class="btn-transparent"
@label="discourse_post_event.show_participants"
@action={{this.showParticipants}}
/>
</dropdown.item>

<dropdown.divider />
{{/if}}
{{#if this.canActOnEvent}}
<dropdown.item class="export-event">
<DButton
@icon="file-csv"
class="btn-transparent"
@label="discourse_post_event.export_event"
@action={{this.exportPostEvent}}
/>
</dropdown.item>

{{#if this.canBulkInvite}}
<dropdown.item class="bulk-invite">
<DButton
@icon="file-arrow-up"
class="btn-transparent"
@label="discourse_post_event.bulk_invite"
@action={{this.bulkInvite}}
/>
</dropdown.item>
{{/if}}

{{#if @event.isClosed}}
<dropdown.item class="open-event">
<DButton
@icon="unlock"
class="btn-transparent"
@label="discourse_post_event.open_event"
@action={{this.openEvent}}
@disabled={{this.isSavingEvent}}
/>
</dropdown.item>
{{else}}
<dropdown.item class="edit-event">
<DButton
@icon="pencil"
class="btn-transparent"
@label="discourse_post_event.edit_event"
@action={{this.editPostEvent}}
/>
</dropdown.item>

{{#unless @event.isExpired}}
<dropdown.item class="close-event">
<DButton
@icon="xmark"
@label="discourse_post_event.close_event"
@action={{this.closeEvent}}
@disabled={{this.isSavingEvent}}
class="btn-transparent btn-danger"
/>
</dropdown.item>
{{/unless}}
{{/if}}
{{/if}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View file

@ -0,0 +1,183 @@
import Component from "@glimmer/component";
import { concat, fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import concatClass from "discourse/helpers/concat-class";
import lazyHash from "discourse/helpers/lazy-hash";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default class DiscoursePostEventStatus extends Component {
@service appEvents;
@service discoursePostEventApi;
@service siteSettings;

get eventButtons() {
return this.siteSettings.event_participation_buttons.split("|");
}

get showGoingButton() {
return !!this.eventButtons.find((button) => button === "going");
}

get showInterestedButton() {
return !!this.eventButtons.find((button) => button === "interested");
}

get showNotGoingButton() {
return !!this.eventButtons.find((button) => button === "not going");
}

get canLeave() {
return this.args.event.watchingInvitee && this.args.event.isPublic;
}

get watchingInviteeStatus() {
return this.args.event.watchingInvitee?.status;
}

@action
async leaveEvent() {
try {
const invitee = this.args.event.watchingInvitee;

await this.discoursePostEventApi.leaveEvent(this.args.event, invitee);

this.appEvents.trigger("calendar:invitee-left-event", {
invitee,
postId: this.args.event.id,
});
} catch (e) {
popupAjaxError(e);
}
}

@action
async updateEventAttendance(status) {
try {
await this.discoursePostEventApi.updateEventAttendance(this.args.event, {
status,
});

this.appEvents.trigger("calendar:update-invitee-status", {
status,
postId: this.args.event.id,
});
} catch (e) {
popupAjaxError(e);
}
}

@action
async joinEventWithStatus(status) {
try {
await this.discoursePostEventApi.joinEvent(this.args.event, {
status,
});

this.appEvents.trigger("calendar:create-invitee-status", {
status,
postId: this.args.event.id,
});
} catch (e) {
popupAjaxError(e);
}
}

@action
async changeWatchingInviteeStatus(status) {
if (this.args.event.watchingInvitee) {
const currentStatus = this.args.event.watchingInvitee.status;
if (this.canLeave) {
if (status === currentStatus) {
await this.leaveEvent();
} else {
await this.updateEventAttendance(status);
}
} else {
if (status === currentStatus) {
status = null;
}

await this.updateEventAttendance(status);
}
} else {
await this.joinEventWithStatus(status);
}
}

<template>
<section
class={{concatClass
"event__section event-actions event-status"
(if
this.watchingInviteeStatus
(concat "status-" this.watchingInviteeStatus)
)
}}
>
<PluginOutlet
@name="discourse-post-event-status-buttons"
@outletArgs={{lazyHash event=@event}}
>
{{#if this.showGoingButton}}
{{#unless @event.minimal}}
<PluginOutlet
@name="discourse-post-event-status-going-button"
@outletArgs={{lazyHash
event=@event
markAsGoing=(fn this.changeWatchingInviteeStatus "going")
}}
>
<DButton
class="going-button"
@icon="check"
@label="discourse_post_event.models.invitee.status.going"
@action={{fn this.changeWatchingInviteeStatus "going"}}
/>
</PluginOutlet>
{{/unless}}
{{/if}}

{{#if this.showInterestedButton}}
<PluginOutlet
@name="discourse-post-event-status-interested-button"
@outletArgs={{lazyHash
event=@event
markAsInterested=(fn
this.changeWatchingInviteeStatus "interested"
)
}}
>
<DButton
class="interested-button"
@icon="star"
@label="discourse_post_event.models.invitee.status.interested"
@action={{fn this.changeWatchingInviteeStatus "interested"}}
/>
</PluginOutlet>
{{/if}}

{{#if this.showNotGoingButton}}
{{#unless @event.minimal}}
<PluginOutlet
@name="discourse-post-event-status-not-going-button"
@outletArgs={{lazyHash
event=@event
markAsNotGoing=(fn this.changeWatchingInviteeStatus "not_going")
}}
>
<DButton
class="not-going-button"
@icon="xmark"
@label="discourse_post_event.models.invitee.status.not_going"
@action={{fn this.changeWatchingInviteeStatus "not_going"}}
/>
</PluginOutlet>
{{/unless}}
{{/if}}
</PluginOutlet>
</section>
</template>
}

View file

@ -0,0 +1,26 @@
import Component from "@glimmer/component";
import icon from "discourse/helpers/d-icon";

export default class DiscoursePostEventUrl extends Component {
get url() {
return this.args.url.includes("://") || this.args.url.includes("mailto:")
? this.args.url
: `https://${this.args.url}`;
}

<template>
{{#if @url}}
<section class="event__section event-url">
{{icon "link"}}
<a
class="url"
href={{this.url}}
target="_blank"
rel="noopener noreferrer"
>
{{@url}}
</a>
</section>
{{/if}}
</template>
}

View file

@ -0,0 +1,112 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { i18n } from "discourse-i18n";
import guessDateFormat from "../lib/guess-best-date-format";

export default class EventDate extends Component {
@service siteSettings;

<template>
{{~#if this.shouldRender~}}
<span class="header-topic-title-suffix-outlet event-date-container">
{{~#if this.siteSettings.use_local_event_date~}}
<span
class="event-date event-local-date past"
title={{this.dateRange}}
data-starts-at={{this.eventStartedAt}}
data-ends-at={{this.eventEndedAt}}
>
{{this.localDateContent}}
</span>
{{else}}
<span
class="event-date event-relative-date {{this.relativeDateType}}"
title={{this.dateRange}}
data-starts-at={{this.eventStartedAt}}
data-ends-at={{this.eventEndedAt}}
>
{{~#if this.isWithinDateRange~}}
<span class="indicator"></span>
<span class="text">{{this.timeRemainingContent}}</span>
{{else}}
{{this.relativeDateContent}}
{{~/if~}}
</span>
{{~/if~}}
</span>
{{~/if~}}
</template>

get shouldRender() {
return (
this.siteSettings.discourse_post_event_enabled &&
this.args.topic.event_starts_at
);
}

get eventStartedAt() {
return this._parsedDate(this.args.topic.event_starts_at);
}

get eventEndedAt() {
return this.args.topic.event_ends_at
? this._parsedDate(this.args.topic.event_ends_at)
: this.eventStartedAt;
}

get dateRange() {
return this.args.topic.event_ends_at
? `${this._formattedDate(this.eventStartedAt)} → ${this._formattedDate(
this.eventEndedAt
)}`
: this._formattedDate(this.eventStartedAt);
}

get localDateContent() {
return this._formattedDate(this.eventStartedAt);
}

get relativeDateType() {
if (this.isWithinDateRange) {
return "current";
}
if (this.eventStartedAt.isAfter(moment())) {
return "future";
}
return "past";
}

get isWithinDateRange() {
return (
this.eventStartedAt.isBefore(moment()) &&
this.eventEndedAt.isAfter(moment())
);
}

get relativeDateContent() {
// dateType "current" uses a different implementation
const relativeDates = {
future: this.eventStartedAt.from(moment()),
past: this.eventEndedAt.from(moment()),
};
return relativeDates[this.relativeDateType];
}

get timeRemainingContent() {
return i18n("discourse_post_event.topic_title.ends_in_duration", {
duration: this.eventEndedAt.from(moment()),
});
}

_parsedDate(date) {
return moment.utc(date).tz(moment.tz.guess());
}

_guessedDateFormat() {
return guessDateFormat(this.eventStartedAt);
}

_formattedDate(date) {
return date.format(this._guessedDateFormat());
}
}

View file

@ -0,0 +1,20 @@
import { notEq } from "truth-helpers";
import { i18n } from "discourse-i18n";

const EventField = <template>
{{#if (notEq @enabled false)}}
<div class="event-field" ...attributes>
{{#if @label}}
<div class="event-field-label">
<span class="label">{{i18n @label}}</span>
</div>
{{/if}}

<div class="event-field-control">
{{yield}}
</div>
</div>
{{/if}}
</template>;

export default EventField;

View file

@ -0,0 +1,184 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
import roundTime from "../../lib/round-time";
import NewDay from "./new-day";
import TimeTraveller from "./time-traveller";
import Timezone from "./timezone";

const nbsp = "\xa0";

export default class GroupTimezones extends Component {
@service siteSettings;

@tracked filter = "";
@tracked localTimeOffset = 0;

get groupedTimezones() {
let groupedTimezones = [];

this.args.members.filterBy("timezone").forEach((member) => {
if (this.#shouldAddMemberToGroup(this.filter, member)) {
const timezone = member.timezone;
const identifier = parseInt(moment.tz(timezone).format("YYYYMDHm"), 10);
let groupedTimezone = groupedTimezones.findBy("identifier", identifier);

if (groupedTimezone) {
groupedTimezone.members.push(member);
} else {
const now = this.#roundMoment(moment.tz(timezone));
const workingDays = this.#workingDays();
const offset = moment.tz(moment.utc(), timezone).utcOffset();

groupedTimezone = {
identifier,
offset,
type: "discourse-group-timezone",
nowWithOffset: now.add(this.localTimeOffset, "minutes"),
closeToWorkingHours: this.#closeToWorkingHours(now, workingDays),
inWorkingHours: this.#inWorkingHours(now, workingDays),
utcOffset: this.#utcOffset(offset),
members: [member],
};
groupedTimezones.push(groupedTimezone);
}
}
});

groupedTimezones = groupedTimezones
.sortBy("offset")
.filter((g) => g.members.length);

let newDayIndex;
groupedTimezones.forEach((groupedTimezone, index) => {
if (index > 0) {
if (
groupedTimezones[index - 1].nowWithOffset.format("dddd") !==
groupedTimezone.nowWithOffset.format("dddd")
) {
newDayIndex = index;
}
}
});

if (newDayIndex) {
groupedTimezones.splice(newDayIndex, 0, {
type: "discourse-group-timezone-new-day",
beforeDate:
groupedTimezones[newDayIndex - 1].nowWithOffset.format("dddd"),
afterDate: groupedTimezones[newDayIndex].nowWithOffset.format("dddd"),
});
}

return groupedTimezones;
}

#shouldAddMemberToGroup(filter, member) {
if (filter) {
filter = filter.toLowerCase();
if (
member.username.toLowerCase().indexOf(filter) > -1 ||
(member.name && member.name.toLowerCase().indexOf(filter) > -1)
) {
return true;
}
} else {
return true;
}

return false;
}

#roundMoment(date) {
if (this.localTimeOffset) {
date = roundTime(date);
}

return date;
}

#closeToWorkingHours(moment, workingDays) {
const hours = moment.hours();
const startHour = this.siteSettings.working_day_start_hour;
const endHour = this.siteSettings.working_day_end_hour;
const extension = this.siteSettings.close_to_working_day_hours_extension;

return (
((hours >= Math.max(startHour - extension, 0) && hours <= startHour) ||
(hours <= Math.min(endHour + extension, 23) && hours >= endHour)) &&
workingDays.includes(moment.isoWeekday())
);
}

#inWorkingHours(moment, workingDays) {
const hours = moment.hours();
return (
hours > this.siteSettings.working_day_start_hour &&
hours < this.siteSettings.working_day_end_hour &&
workingDays.includes(moment.isoWeekday())
);
}

#utcOffset(offset) {
const sign = Math.sign(offset) === 1 ? "+" : "-";
offset = Math.abs(offset);
let hours = Math.floor(offset / 60).toString();
hours = hours.length === 1 ? `0${hours}` : hours;
let minutes = (offset % 60).toString();
minutes = minutes.length === 1 ? `:${minutes}0` : `:${minutes}`;
return `${sign}${hours.replace(/^0(\d)/, "$1")}${minutes.replace(
/:00$/,
""
)}`.replace(/-0/, nbsp);
}

#workingDays() {
const enMoment = moment().locale("en");
const getIsoWeekday = (day) =>
enMoment.localeData()._weekdays.indexOf(day) || 7;
return this.siteSettings.working_days
.split("|")
.filter(Boolean)
.map((x) => getIsoWeekday(x));
}

@action
handleFilterChange(event) {
this.filter = event.target.value;
}

<template>
<div class="group-timezones-header">
<TimeTraveller
@localTimeOffset={{this.localTimeOffset}}
@setOffset={{fn (mut this.localTimeOffset)}}
/>
<span class="title">
{{i18n "group_timezones.group_availability" group=@group}}
</span>
<input
type="text"
placeholder={{i18n "group_timezones.search"}}
class="group-timezones-filter"
{{on "input" this.handleFilterChange}}
/>
</div>
<div class="group-timezones-body">
{{#each this.groupedTimezones key="identifier" as |groupedTimezone|}}
{{#if (eq groupedTimezone.type "discourse-group-timezone-new-day")}}
<NewDay
@beforeDate={{groupedTimezone.beforeDate}}
@afterDate={{groupedTimezone.afterDate}}
/>
{{else}}
<Timezone @groupedTimezone={{groupedTimezone}} />
{{/if}}
{{/each}}
</div>
</template>
}

View file

@ -0,0 +1,16 @@
import icon from "discourse/helpers/d-icon";

const NewDay = <template>
<div class="group-timezone-new-day">
<span class="before">
{{icon "chevron-left"}}
{{@beforeDate}}
</span>
<span class="after">
{{@afterDate}}
{{icon "chevron-right"}}
</span>
</div>
</template>;

export default NewDay;

View file

@ -0,0 +1,58 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import roundTime from "../../lib/round-time";

export default class TimeTraveller extends Component {
get localTimeWithOffset() {
let date = moment().add(this.args.localTimeOffset, "minutes");

if (this.args.localTimeOffset) {
date = roundTime(date);
}

return date.format("HH:mm");
}

@action
reset() {
this.args.setOffset(0);
}

@action
sliderMoved(event) {
const value = parseInt(event.target.value, 10);
const offset = value * 15;
this.args.setOffset(offset);
}

<template>
<div class="group-timezones-time-traveler">
<span class="time">
{{this.localTimeWithOffset}}
</span>

<span class="discourse-group-timezones-slider-wrapper">
<input
class="group-timezones-slider"
{{on "input" this.sliderMoved}}
step="1"
value="0"
type="range"
min="-48"
max="48"
/>
</span>

<div class="group-timezones-reset">
<DButton
disabled={{not @localTimeOffset}}
@action={{this.reset}}
@icon="arrow-rotate-left"
/>
</div>
</div>
</template>
}

View file

@ -0,0 +1,44 @@
import Component from "@glimmer/component";
import UserAvatar from "discourse/components/user-avatar";
import concatClass from "discourse/helpers/concat-class";

export default class GroupTimezone extends Component {
get formattedTime() {
return this.args.groupedTimezone.nowWithOffset.format("LT");
}

<template>
<div
class={{concatClass
"group-timezone"
(if @groupedTimezone.closeToWorkingHours "close-to-working-hours")
(if @groupedTimezone.inWorkingHours "in-working-hours")
}}
>
<div class="info">
<span class="time">
{{this.formattedTime}}
</span>
<span class="offset" title="UTC offset">
{{@groupedTimezone.utcOffset}}
</span>
</div>
<ul class="group-timezones-members">
{{#each @groupedTimezone.members key="username" as |member|}}
<li
class={{concatClass
"group-timezones-member"
(if member.on_holiday "on-holiday" "not-on-holiday")
}}
>
<UserAvatar
@user={{member}}
@size="small"
class="group-timezones-member-avatar"
/>
</li>
{{/each}}
</ul>
</div>
</template>
}

View file

@ -0,0 +1,690 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input, Textarea } from "@ember/component";
import { concat, fn, get } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import DateInput from "discourse/components/date-input";
import DateTimeInputRange from "discourse/components/date-time-input-range";
import GroupSelector from "discourse/components/group-selector";
import PluginOutlet from "discourse/components/plugin-outlet";
import RadioButton from "discourse/components/radio-button";
import lazyHash from "discourse/helpers/lazy-hash";
import { extractError } from "discourse/lib/ajax-error";
import { cook } from "discourse/lib/text";
import Group from "discourse/models/group";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import TimezoneInput from "select-kit/components/timezone-input";
import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
import EventField from "../event-field";

export default class PostEventBuilder extends Component {
@service dialog;
@service siteSettings;
@service store;
@service currentUser;

@tracked flash = null;
@tracked isSaving = false;

@tracked startsAt = moment(this.event.startsAt).tz(
this.event.timezone || "UTC"
);

@tracked
endsAt =
this.event.endsAt &&
moment(this.event.endsAt).tz(this.event.timezone || "UTC");

get recurrenceUntil() {
return (
this.event.recurrenceUntil &&
moment(this.event.recurrenceUntil).tz(this.event.timezone || "UTC")
);
}

get event() {
return this.args.model.event;
}

get reminderTypes() {
return [
{
value: "notification",
name: i18n(
"discourse_post_event.builder_modal.reminders.types.notification"
),
},
{
value: "bumpTopic",
name: i18n(
"discourse_post_event.builder_modal.reminders.types.bump_topic"
),
},
];
}

get reminderUnits() {
return [
{
value: "minutes",
name: i18n(
"discourse_post_event.builder_modal.reminders.units.minutes"
),
},
{
value: "hours",
name: i18n("discourse_post_event.builder_modal.reminders.units.hours"),
},
{
value: "days",
name: i18n("discourse_post_event.builder_modal.reminders.units.days"),
},
{
value: "weeks",
name: i18n("discourse_post_event.builder_modal.reminders.units.weeks"),
},
];
}

get reminderPeriods() {
return [
{
value: "before",
name: i18n(
"discourse_post_event.builder_modal.reminders.periods.before"
),
},
{
value: "after",
name: i18n(
"discourse_post_event.builder_modal.reminders.periods.after"
),
},
];
}

get shouldRenderUrl() {
return this.args.model.event.url !== undefined;
}

get availableRecurrences() {
return [
{
id: "every_day",
name: i18n("discourse_post_event.builder_modal.recurrence.every_day"),
},
{
id: "every_month",
name: i18n("discourse_post_event.builder_modal.recurrence.every_month"),
},
{
id: "every_weekday",
name: i18n(
"discourse_post_event.builder_modal.recurrence.every_weekday"
),
},
{
id: "every_week",
name: i18n("discourse_post_event.builder_modal.recurrence.every_week"),
},
{
id: "every_two_weeks",
name: i18n(
"discourse_post_event.builder_modal.recurrence.every_two_weeks"
),
},
{
id: "every_four_weeks",
name: i18n(
"discourse_post_event.builder_modal.recurrence.every_four_weeks"
),
},
];
}

get allowedCustomFields() {
return this.siteSettings.discourse_post_event_allowed_custom_fields
.split("|")
.filter(Boolean);
}

get addReminderDisabled() {
return this.event.reminders?.length >= 5;
}

get showChat() {
// As of June 2025, chat channel creation is only available to admins and moderators
return (
this.siteSettings.chat_enabled &&
(this.currentUser.admin || this.currentUser.moderator)
);
}

@action
groupFinder(term) {
return Group.findAll({ term, ignore_automatic: true });
}

@action
setCustomField(field, e) {
this.event.customFields[field] = e.target.value;
}

@action
onChangeDates(dates) {
this.event.startsAt = dates.from;
this.event.endsAt = dates.to;
this.startsAt = dates.from;
this.endsAt = dates.to;
}

@action
onChangeStatus(newStatus) {
this.event.rawInvitees = [];
this.event.status = newStatus;
}

@action
setRecurrence(newRecurrence) {
if (!newRecurrence) {
this.event.recurrence = null;
this.event.recurrenceUntil = null;
return;
}

this.event.recurrence = newRecurrence;
}

@action
setRecurrenceUntil(until) {
if (!until) {
this.event.recurrenceUntil = null;
} else {
this.event.recurrenceUntil = moment(until).endOf("day").toDate();
}
}

@action
setRawInvitees(_, newInvitees) {
this.event.rawInvitees = newInvitees;
}

@action
setNewTimezone(newTz) {
this.event.timezone = newTz;
this.event.startsAt = moment.tz(
this.startsAt.format("YYYY-MM-DDTHH:mm"),
newTz
);
this.event.endsAt = this.endsAt
? moment.tz(this.endsAt.format("YYYY-MM-DDTHH:mm"), newTz)
: null;
this.startsAt = moment(this.event.startsAt).tz(newTz);
this.endsAt = this.event.endsAt
? moment(this.event.endsAt).tz(newTz)
: null;
}

@action
async destroyPostEvent() {
try {
const confirmResult = await this.dialog.yesNoConfirm({
message: "Confirm delete",
});

if (confirmResult) {
const post = await this.store.find("post", this.event.id);
const raw = post.raw;
const newRaw = this._removeRawEvent(raw);
const props = {
raw: newRaw,
edit_reason: "Destroy event",
};

const cooked = await cook(newRaw);
props.cooked = cooked.string;

const result = await post.save(props);
if (result) {
this.args.closeModal();
}
}
} catch (e) {
this.flash = extractError(e);
}
}

@action
createEvent() {
if (!this.startsAt) {
this.args.closeModal();
return;
}

const eventParams = buildParams(
this.startsAt,
this.endsAt,
this.event,
this.siteSettings
);
const markdownParams = [];
Object.keys(eventParams).forEach((key) => {
let value = eventParams[key];
markdownParams.push(`${key}="${value}"`);
});

this.args.model.toolbarEvent.addText(
`[event ${markdownParams.join(" ")}]\n[/event]`
);
this.args.closeModal();
}

@action
async updateEvent() {
try {
this.isSaving = true;

const post = await this.store.find("post", this.event.id);
const raw = post.raw;
const eventParams = buildParams(
this.startsAt,
this.endsAt,
this.event,
this.siteSettings
);
const newRaw = replaceRaw(eventParams, raw);
if (newRaw) {
const props = {
raw: newRaw,
edit_reason: i18n("discourse_post_event.edit_reason"),
};

const cooked = await cook(newRaw);
props.cooked = cooked.string;

const result = await post.save(props);
if (result) {
this.args.closeModal();
}
}
} catch (e) {
this.flash = extractError(e);
} finally {
this.isSaving = false;
}
}

_removeRawEvent(raw) {
const eventRegex = new RegExp(`\\[event\\s(.*?)\\]\\n\\[\\/event\\]`, "m");
return raw.replace(eventRegex, "");
}

<template>
<DModal
@title={{i18n
(concat
"discourse_post_event.builder_modal."
(if @model.event.id "update_event_title" "create_event_title")
)
}}
@closeModal={{@closeModal}}
@flash={{this.flash}}
class="post-event-builder-modal"
>
<:body>
<ConditionalLoadingSection @isLoading={{this.isSaving}}>
<form>
<PluginOutlet
@name="post-event-builder-form"
@outletArgs={{lazyHash event=@model.event}}
@connectorTagName="div"
>
<EventField>
<DateTimeInputRange
@from={{this.startsAt}}
@to={{this.endsAt}}
@timezone={{@model.event.timezone}}
@onChange={{this.onChangeDates}}
/>
</EventField>

<EventField
@label="discourse_post_event.builder_modal.name.label"
class="name"
>
<Input
@value={{@model.event.name}}
placeholder={{i18n
"discourse_post_event.builder_modal.name.placeholder"
}}
/>
</EventField>

<EventField
@label="discourse_post_event.builder_modal.location.label"
class="location"
>
<Input
@value={{@model.event.location}}
placeholder={{i18n
"discourse_post_event.builder_modal.location.placeholder"
}}
/>
</EventField>

{{#if this.shouldRenderUrl}}
<EventField
@label="discourse_post_event.builder_modal.url.label"
class="url"
>
<Input
@value={{@model.event.url}}
placeholder={{i18n
"discourse_post_event.builder_modal.url.placeholder"
}}
/>
</EventField>
{{/if}}

<EventField
@label="discourse_post_event.builder_modal.description.label"
class="description"
>
<Textarea
@value={{@model.event.description}}
placeholder={{i18n
"discourse_post_event.builder_modal.description.placeholder"
}}
/>
</EventField>

<EventField
class="timezone"
@label="discourse_post_event.builder_modal.timezone.label"
>
<TimezoneInput
@value={{@model.event.timezone}}
@onChange={{this.setNewTimezone}}
@none="discourse_post_event.builder_modal.timezone.remove_timezone"
/>
</EventField>

<EventField
class="show-local-time"
@label="discourse_post_event.builder_modal.show_local_time.label"
>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@model.event.showLocalTime}}
/>
<span class="message">
{{i18n
"discourse_post_event.builder_modal.show_local_time.description"
timezone=@model.event.timezone
}}
</span>
</label>
</EventField>

<EventField
@label="discourse_post_event.builder_modal.status.label"
>
<label class="radio-label">
<RadioButton
@name="status"
@value="public"
@selection={{@model.event.status}}
@onChange={{this.onChangeStatus}}
/>
<span class="message">
<span class="title">
{{i18n
"discourse_post_event.models.event.status.public.title"
}}
</span>
<span class="description">
{{i18n
"discourse_post_event.models.event.status.public.description"
}}
</span>
</span>
</label>
<label class="radio-label">
<RadioButton
@name="status"
@value="private"
@selection={{@model.event.status}}
@onChange={{this.onChangeStatus}}
/>
<span class="message">
<span class="title">
{{i18n
"discourse_post_event.models.event.status.private.title"
}}
</span>
<span class="description">
{{i18n
"discourse_post_event.models.event.status.private.description"
}}
</span>
</span>
</label>
<label class="radio-label">
<RadioButton
@name="status"
@value="standalone"
@selection={{@model.event.status}}
@onChange={{this.onChangeStatus}}
/>
<span class="message">
<span class="title">
{{i18n
"discourse_post_event.models.event.status.standalone.title"
}}
</span>
<span class="description">
{{i18n
"discourse_post_event.models.event.status.standalone.description"
}}
</span>
</span>
</label>
</EventField>

<EventField
@enabled={{eq @model.event.status "private"}}
@label="discourse_post_event.builder_modal.invitees.label"
>
<GroupSelector
@fullWidthWrap={{true}}
@groupFinder={{this.groupFinder}}
@groupNames={{@model.event.rawInvitees}}
@onChangeCallback={{this.setRawInvitees}}
@placeholderKey="topic.invite_private.group_name"
/>
</EventField>

<EventField
class="reminders"
@label="discourse_post_event.builder_modal.reminders.label"
>
<div class="reminders-list">
{{#each @model.event.reminders as |reminder|}}
<div class="reminder-item">
<ComboBox
@value={{reminder.type}}
@nameProperty="name"
@valueProperty="value"
@content={{this.reminderTypes}}
class="reminder-type"
/>

<Input
@value={{reminder.value}}
min="0"
placeholder={{i18n
"discourse_post_event.builder_modal.name.placeholder"
}}
class="reminder-value"
/>

<ComboBox
@value={{reminder.unit}}
@nameProperty="name"
@valueProperty="value"
@content={{this.reminderUnits}}
class="reminder-unit"
/>

<ComboBox
@value={{reminder.period}}
@nameProperty="name"
@valueProperty="value"
@content={{this.reminderPeriods}}
class="reminder-period"
/>

<DButton
@action={{fn @model.event.removeReminder reminder}}
@icon="xmark"
class="remove-reminder"
/>

</div>
{{/each}}
</div>

<DButton
@disabled={{this.addReminderDisabled}}
@icon="plus"
@label="discourse_post_event.builder_modal.add_reminder"
@action={{@model.event.addReminder}}
class="add-reminder"
/>
</EventField>

<EventField
class="recurrence"
@label="discourse_post_event.builder_modal.recurrence.label"
>
<ComboBox
class="available-recurrences"
@value={{@model.event.recurrence}}
@content={{this.availableRecurrences}}
@onChange={{this.setRecurrence}}
@options={{lazyHash
none="discourse_post_event.builder_modal.recurrence.none"
}}
/>
</EventField>

{{#if @model.event.recurrence}}
<EventField
@label="discourse_post_event.builder_modal.recurrence_until.label"
class="recurrence-until"
>
<DateInput
@date={{this.recurrenceUntil}}
@onChange={{this.setRecurrenceUntil}}
@timezone={{@model.event.timezone}}
/>
</EventField>
{{/if}}

<EventField
class="minimal-event"
@label="discourse_post_event.builder_modal.minimal.label"
>
<label class="checkbox-label">
<Input @type="checkbox" @checked={{@model.event.minimal}} />
<span class="message">
{{i18n
"discourse_post_event.builder_modal.minimal.checkbox_label"
}}
</span>
</label>
</EventField>

{{#if this.showChat}}
<EventField
class="allow-chat"
@label="discourse_post_event.builder_modal.allow_chat.label"
>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{@model.event.chatEnabled}}
/>
<span class="message">
{{i18n
"discourse_post_event.builder_modal.allow_chat.checkbox_label"
}}
</span>
</label>
</EventField>
{{/if}}

{{#if this.allowedCustomFields.length}}
<EventField
@label="discourse_post_event.builder_modal.custom_fields.label"
>
<p class="event-field-description">
{{i18n
"discourse_post_event.builder_modal.custom_fields.description"
}}
</p>
{{#each this.allowedCustomFields as |allowedCustomField|}}
<span class="label custom-field-label">
{{allowedCustomField}}
</span>
<Input
{{on "input" (fn this.setCustomField allowedCustomField)}}
@value={{readonly
(get @model.event.customFields allowedCustomField)
}}
placeholder={{i18n
"discourse_post_event.builder_modal.custom_fields.placeholder"
}}
class="custom-field-input"
/>
{{/each}}
</EventField>
{{/if}}
</PluginOutlet>
</form>
</ConditionalLoadingSection>
</:body>
<:footer>
{{#if @model.event.id}}
<DButton
class="btn-primary"
@label="discourse_post_event.builder_modal.update"
@icon="calendar-day"
@action={{this.updateEvent}}
/>

<DButton
@icon="trash-can"
class="btn-danger"
@action={{this.destroyPostEvent}}
/>
{{else}}
<DButton
class="btn-primary"
@label="discourse_post_event.builder_modal.create"
@icon="calendar-day"
@action={{this.createEvent}}
/>
{{/if}}
</:footer>
</DModal>
</template>
}

View file

@ -0,0 +1,227 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat, fn, hash } from "@ember/helper";
import EmberObject, { action } from "@ember/object";
import { service } from "@ember/service";
import { isPresent } from "@ember/utils";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import GroupSelector from "discourse/components/group-selector";
import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
import BulkInviteSampleCsvFile from "../bulk-invite-sample-csv-file";
import CsvUploader from "../csv-uploader";

export default class PostEventBulkInvite extends Component {
@service dialog;

@tracked
bulkInvites = new TrackedArray([
EmberObject.create({ identifier: null, attendance: "unknown" }),
]);
@tracked bulkInviteDisabled = true;
@tracked flash = null;

get bulkInviteStatuses() {
return [
{
label: i18n("discourse_post_event.models.invitee.status.unknown"),
name: "unknown",
},
{
label: i18n("discourse_post_event.models.invitee.status.going"),
name: "going",
},
{
label: i18n("discourse_post_event.models.invitee.status.not_going"),
name: "not_going",
},
{
label: i18n("discourse_post_event.models.invitee.status.interested"),
name: "interested",
},
];
}

@action
groupFinder(term) {
return Group.findAll({ term, ignore_automatic: true });
}

@action
setBulkInviteDisabled() {
this.bulkInviteDisabled =
this.bulkInvites.filter((x) => isPresent(x.identifier)).length === 0;
}

@action
async sendBulkInvites() {
try {
const response = await ajax(
`/discourse-post-event/events/${this.args.model.event.id}/bulk-invite.json`,
{
type: "POST",
dataType: "json",
contentType: "application/json",
data: JSON.stringify({
invitees: this.bulkInvites.filter((x) => isPresent(x.identifier)),
}),
}
);

if (response.success) {
this.args.closeModal();
}
} catch (e) {
this.flash = extractError(e);
}
}

@action
removeBulkInvite(bulkInvite) {
this.bulkInvites.removeObject(bulkInvite);

if (!this.bulkInvites.length) {
this.bulkInvites.pushObject(
EmberObject.create({ identifier: null, attendance: "unknown" })
);
}
}

@action
addBulkInvite() {
const attendance =
this.bulkInvites[this.bulkInvites.length - 1]?.attendance || "unknown";
this.bulkInvites.pushObject(
EmberObject.create({ identifier: null, attendance })
);
}

@action
async uploadDone() {
await this.dialog.alert(
i18n("discourse_post_event.bulk_invite_modal.success")
);
this.args.closeModal();
}

@action
updateInviteIdentifier(bulkInvite, selected) {
bulkInvite.set("identifier", selected[0]);
this.setBulkInviteDisabled();
}

@action
updateBulkGroupInviteIdentifier(bulkInvite, _, groupNames) {
bulkInvite.set("identifier", groupNames[0]);
this.setBulkInviteDisabled();
}

<template>
<DModal
@closeModal={{@closeModal}}
@title={{i18n "discourse_post_event.bulk_invite_modal.title"}}
class="post-event-bulk-invite"
@flash={{this.flash}}
>
<:body>
<div class="bulk-invites">
<p class="bulk-event-help">
{{i18n
(concat
"discourse_post_event.bulk_invite_modal.description_"
@model.event.status
)
}}
</p>
<h3>{{i18n
"discourse_post_event.bulk_invite_modal.inline_title"
}}</h3>

<div class="bulk-invite-rows">
{{#each this.bulkInvites as |bulkInvite|}}
<div class="bulk-invite-row">
{{#if @model.event.isPrivate}}
<GroupSelector
class="bulk-invite-identifier"
@single={{true}}
@groupFinder={{this.groupFinder}}
@groupNames={{bulkInvite.identifier}}
@placeholderKey="discourse_post_event.bulk_invite_modal.group_selector_placeholder"
@onChangeCallback={{fn
this.updateBulkGroupInviteIdentifier
bulkInvite
}}
/>
{{/if}}
{{#if @model.event.isPublic}}
<EmailGroupUserChooser
class="bulk-invite-identifier"
@value={{bulkInvite.identifier}}
@onChange={{fn this.updateInviteIdentifier bulkInvite}}
@options={{hash
maximum=1
filterPlaceholder="discourse_post_event.bulk_invite_modal.user_selector_placeholder"
}}
/>
{{/if}}

<ComboBox
class="bulk-invite-attendance"
@value={{bulkInvite.attendance}}
@content={{this.bulkInviteStatuses}}
@nameProperty="name"
@valueProperty="name"
@onChange={{fn (mut bulkInvite.attendance)}}
/>

<DButton
@icon="trash-can"
@action={{fn this.removeBulkInvite bulkInvite}}
class="remove-bulk-invite"
/>
</div>
{{/each}}
</div>

<div class="bulk-invite-actions">
<DButton
class="send-bulk-invites btn-primary"
@label="discourse_post_event.bulk_invite_modal.send_bulk_invites"
@action={{this.sendBulkInvites}}
@disabled={{this.bulkInviteDisabled}}
/>
<DButton
class="add-bulk-invite"
@icon="plus"
@action={{this.addBulkInvite}}
/>
</div>
</div>

<div class="csv-bulk-invites">
<h3>{{i18n "discourse_post_event.bulk_invite_modal.csv_title"}}</h3>

<div class="bulk-invite-actions">
<BulkInviteSampleCsvFile />

<CsvUploader
@uploadUrl={{concat
"/discourse-post-event/events/"
@model.event.id
"/csv-bulk-invite"
}}
@i18nPrefix="discourse_post_event.bulk_invite_modal"
@uploadDone={{this.uploadDone}}
/>
</div>
</div>
</:body>
</DModal>
</template>
}

View file

@ -0,0 +1,64 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { ajax } from "discourse/lib/ajax";
import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
import EventField from "../event-field";

export default class PostEventInviteUserOrGroup extends Component {
@tracked invitedNames = [];
@tracked flash = null;

@action
async invite() {
try {
await ajax(
`/discourse-post-event/events/${this.args.model.event.id}/invite.json`,
{
data: { invites: this.invitedNames },
type: "POST",
}
);
this.args.closeModal();
} catch (e) {
this.flash = extractError(e);
}
}

<template>
<DModal
@title={{i18n "discourse_post_event.invite_user_or_group.title"}}
@closeModal={{@closeModal}}
@flash={{this.flash}}
>
<:body>
<form>
<EventField>
<EmailGroupUserChooser
@value={{this.invitedNames}}
@options={{hash
fullWidthWrap=true
includeMessageableGroups=true
filterPlaceholder="composer.users_placeholder"
excludeCurrentUser=true
}}
/>
</EventField>
</form>
</:body>
<:footer>
<DButton
@type="button"
class="btn-primary"
@label="discourse_post_event.invite_user_or_group.invite"
@action={{this.invite}}
/>
</:footer>
</DModal>
</template>
}

View file

@ -0,0 +1,157 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { or } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class";
import { debounce } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import ToggleInvitees from "../../toggle-invitees";
import User from "./user";

export default class PostEventInviteesModal extends Component {
@service store;
@service discoursePostEventApi;

@tracked filter;
@tracked isLoading = false;
@tracked type = "going";
@tracked inviteesList;

constructor() {
super(...arguments);
this._fetchInvitees();
}

get hasSuggestedUsers() {
return this.inviteesList?.suggestedUsers?.length > 0;
}

get hasResults() {
return this.inviteesList?.invitees?.length > 0 || this.hasSuggestedUsers;
}

get title() {
return i18n(
`discourse_post_event.invitees_modal.${
this.args.model.title || "title_invited"
}`
);
}

@action
toggleType(type) {
this.type = type;
this._fetchInvitees(this.filter);
}

@debounce(250)
onFilterChanged(event) {
this.filter = event.target.value;
this._fetchInvitees(this.filter);
}

@action
async removeInvitee(invitee) {
await this.discoursePostEventApi.leaveEvent(this.args.model.event, invitee);

this.inviteesList.remove(invitee);
}

@action
async addInvitee(user) {
const invitee = await this.discoursePostEventApi.joinEvent(
this.args.model.event,
{
status: this.type,
user_id: user.id,
}
);

this.inviteesList.add(invitee);
}

async _fetchInvitees(filter) {
try {
this.isLoading = true;

this.inviteesList = await this.discoursePostEventApi.listEventInvitees(
this.args.model.event,
{ type: this.type, filter }
);
} finally {
this.isLoading = false;
}
}

<template>
<DModal
@title={{this.title}}
@closeModal={{@closeModal}}
class={{concatClass
(or @model.extraClass "invited")
"post-event-invitees-modal"
}}
>
<:body>
<input
{{on "input" this.onFilterChanged}}
type="text"
placeholder={{i18n
"discourse_post_event.invitees_modal.filter_placeholder"
}}
class="filter"
/>

<ToggleInvitees @viewType={{this.type}} @toggle={{this.toggleType}} />
<ConditionalLoadingSpinner @condition={{this.isLoading}}>
{{#if this.hasResults}}
<ul class="invitees">
{{#each this.inviteesList.invitees as |invitee|}}
<li class="invitee">
<User @user={{invitee.user}} />
{{#if @model.event.canActOnDiscoursePostEvent}}
<DButton
class="remove-invitee"
@icon="trash-can"
@action={{fn this.removeInvitee invitee}}
title={{i18n
"discourse_post_event.invitees_modal.remove_invitee"
}}
/>
{{/if}}
</li>
{{/each}}
</ul>
{{#if this.hasSuggestedUsers}}
<ul class="possible-invitees">
{{#each this.inviteesList.suggestedUsers as |user|}}
<li class="invitee">
<User @user={{user}} />
<DButton
class="add-invitee"
@icon="plus"
@action={{fn this.addInvitee user}}
title={{i18n
"discourse_post_event.invitees_modal.add_invitee"
}}
/>
</li>
{{/each}}
</ul>
{{/if}}
{{else}}
<p class="no-users">
{{i18n "discourse_post_event.models.invitee.no_users"}}
</p>
{{/if}}
</ConditionalLoadingSpinner>
</:body>
</DModal>
</template>
}

View file

@ -0,0 +1,16 @@
import avatar from "discourse/helpers/avatar";
import { userPath } from "discourse/lib/url";
import { formatUsername } from "discourse/lib/utilities";

const User = <template>
<a href={{userPath @user.username}} data-user-card={{@user.username}}>
<span class="user">
{{avatar @user imageSize="medium"}}
<span class="username">
{{formatUsername @user.username}}
</span>
</span>
</a>
</template>;

export default User;

View file

@ -0,0 +1,44 @@
import { computed } from "@ember/object";
import { classNames } from "@ember-decorators/component";
import { i18n } from "discourse-i18n";
import ComboBoxComponent from "select-kit/components/combo-box";
import {
pluginApiIdentifiers,
selectKitOptions,
} from "select-kit/components/select-kit";
import { HOLIDAY_REGIONS } from "../lib/regions";

@selectKitOptions({
filterable: true,
allowAny: false,
})
@pluginApiIdentifiers("timezone-input")
@classNames("timezone-input", "region-input")
export default class RegionInput extends ComboBoxComponent {
allowNoneRegion = false;

@computed
get content() {
const localeNames = {};
let regions = [];

JSON.parse(this.siteSettings.available_locales).forEach((locale) => {
localeNames[locale.value] = locale.name;
});

if (this.allowNoneRegion === true) {
regions.push({
name: i18n("discourse_calendar.region.none"),
id: null,
});
}

regions = regions.concat(
HOLIDAY_REGIONS.map((region) => ({
name: i18n(`discourse_calendar.region.names.${region}`),
id: region,
})).sort((a, b) => a.name.localeCompare(b.name))
);
return regions;
}
}

View file

@ -0,0 +1,37 @@
import { fn } from "@ember/helper";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";

const ToggleInvitees = <template>
<div class="invitees-type-filter">
<DButton
@label="discourse_post_event.models.invitee.status.going"
class={{concatClass
"btn toggle-going"
(if (eq @viewType "going") "btn-danger" "btn-default")
}}
@action={{fn @toggle "going"}}
/>

<DButton
@label="discourse_post_event.models.invitee.status.interested"
class={{concatClass
"btn toggle-interested"
(if (eq @viewType "interested") "btn-danger" "btn-default")
}}
@action={{fn @toggle "interested"}}
/>

<DButton
@label="discourse_post_event.models.invitee.status.not_going"
class={{concatClass
"btn toggle-not-going"
(if (eq @viewType "not_going") "btn-danger" "btn-default")
}}
@action={{fn @toggle "not_going"}}
/>
</div>
</template>;

export default ToggleInvitees;

View file

@ -0,0 +1,212 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { LinkTo } from "@ember/routing";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import getURL from "discourse/lib/get-url";
import loadScript from "discourse/lib/load-script";
import Category from "discourse/models/category";
import { i18n } from "discourse-i18n";
import { formatEventName } from "../helpers/format-event-name";
import addRecurrentEvents from "../lib/add-recurrent-events";
import fullCalendarDefaultOptions from "../lib/full-calendar-default-options";
import { isNotFullDayEvent } from "../lib/guess-best-date-format";

export default class UpcomingEventsCalendar extends Component {
@service currentUser;
@service site;
@service router;

_calendar = null;

get displayFilters() {
return this.currentUser && this.args.controller;
}

@action
teardown() {
this._calendar?.destroy?.();
this._calendar = null;
}

@action
async renderCalendar() {
const siteSettings = this.site.siteSettings;
const isMobileView = this.site.mobileView;

const calendarNode = document.getElementById("upcoming-events-calendar");
if (!calendarNode) {
return;
}

calendarNode.innerHTML = "";

await this._loadCalendar();

const view =
this.args.controller?.view || (isMobileView ? "listNextYear" : "month");

const fullCalendar = new window.FullCalendar.Calendar(calendarNode, {
...fullCalendarDefaultOptions(),
timeZone: this.currentUser?.user_option?.timezone || "local",
firstDay: 1,
height: "auto",
defaultView: view,
views: {
listNextYear: {
type: "list",
duration: { days: 365 },
buttonText: "list",
listDayFormat: {
month: "long",
year: "numeric",
day: "numeric",
weekday: "long",
},
},
},
header: {
left: "prev,next today",
center: "title",
right: "month,basicWeek,listNextYear",
},
datesRender: (info) => {
// this is renamed in FullCalendar v5 / v6 to datesSet
// in unit tests we skip
if (this.router?.transitionTo) {
this.router.transitionTo({ queryParams: { view: info.view.type } });
}
},
eventPositioned: (info) => {
if (siteSettings.events_max_rows === 0) {
return;
}

let fcContent = info.el.querySelector(".fc-content");

if (!fcContent) {
return;
}

let computedStyle = window.getComputedStyle(fcContent);
let lineHeight = parseInt(computedStyle.lineHeight, 10);

if (lineHeight === 0) {
lineHeight = 20;
}
let maxHeight = lineHeight * siteSettings.events_max_rows;

if (fcContent) {
fcContent.style.maxHeight = `${maxHeight}px`;
}

let fcTitle = info.el.querySelector(".fc-title");
if (fcTitle) {
fcTitle.style.overflow = "hidden";
fcTitle.style.whiteSpace = "pre-wrap";
}
fullCalendar.updateSize();
},
});
this._calendar = fullCalendar;

const tagsColorsMap = JSON.parse(siteSettings.map_events_to_color);

const resolvedEvents = this.args.events
? await this.args.events
: await this.args.controller.model;
const originalEventAndRecurrents = addRecurrentEvents(resolvedEvents);

(originalEventAndRecurrents || []).forEach((event) => {
const { startsAt, endsAt, post, categoryId } = event;

let backgroundColor;

if (post.topic.tags) {
const tagColorEntry = tagsColorsMap.find(
(entry) =>
entry.type === "tag" && post.topic.tags.includes(entry.slug)
);
backgroundColor = tagColorEntry?.color;
}

if (!backgroundColor) {
const categoryColorEntry = tagsColorsMap.find(
(entry) =>
entry.type === "category" && entry.slug === post.topic.category_slug
);
backgroundColor = categoryColorEntry?.color;
}

const categoryColor = Category.findById(categoryId)?.color;
if (!backgroundColor && categoryColor) {
backgroundColor = `#${categoryColor}`;
}

let classNames;
if (moment(endsAt || startsAt).isBefore(moment())) {
classNames = "fc-past-event";
}

this._calendar.addEvent({
title: formatEventName(event, this.currentUser?.user_option?.timezone),
start: startsAt,
end: endsAt || startsAt,
allDay: !isNotFullDayEvent(moment(startsAt), moment(endsAt)),
url: getURL(`/t/-/${post.topic.id}/${post.post_number}`),
backgroundColor,
classNames,
});
});

this._calendar.render();
}

_loadCalendar() {
return new Promise((resolve) => {
loadScript(
"/plugins/discourse-calendar/javascripts/fullcalendar-with-moment-timezone.min.js"
).then(() => {
schedule("afterRender", () => {
if (this.isDestroying || this.isDestroyed) {
return;
}

resolve();
});
});
});
}

<template>
{{#if this.displayFilters}}
<ul class="events-filter nav nav-pills">
<li>
<LinkTo
@route="discourse-post-event-upcoming-events.index"
class="btn-small"
>
{{i18n "discourse_post_event.upcoming_events.all_events"}}
</LinkTo>
</li>
<li>
<LinkTo
@route="discourse-post-event-upcoming-events.mine"
class="btn-small"
>
{{i18n "discourse_post_event.upcoming_events.my_events"}}
</LinkTo>
</li>
</ul>
{{/if}}

<div
id="upcoming-events-calendar"
{{didInsert this.renderCalendar}}
{{willDestroy this.teardown}}
></div>
</template>
}

View file

@ -0,0 +1,222 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { or } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import { ajax } from "discourse/lib/ajax";
import { i18n } from "discourse-i18n";
import { isNotFullDayEvent } from "../lib/guess-best-date-format";

export const DEFAULT_TIME_FORMAT = "LT";
const DEFAULT_UPCOMING_DAYS = 180;
const DEFAULT_COUNT = 8;

function addToResult(date, item, result) {
const day = date.format("DD");
const monthKey = date.format("YYYY-MM");

result[monthKey] = result[monthKey] ?? {};
result[monthKey][day] = result[monthKey][day] ?? [];
result[monthKey][day].push(item);
}

export default class UpcomingEventsList extends Component {
@service appEvents;
@service siteSettings;
@service router;

@tracked isLoading = true;
@tracked hasError = false;
@tracked eventsByMonth = {};

timeFormat = this.args.params?.timeFormat ?? DEFAULT_TIME_FORMAT;
count = this.args.params?.count ?? DEFAULT_COUNT;
upcomingDays = this.args.params?.upcomingDays ?? DEFAULT_UPCOMING_DAYS;

emptyMessage = i18n("discourse_post_event.upcoming_events_list.empty");
allDayLabel = i18n("discourse_post_event.upcoming_events_list.all_day");
errorMessage = i18n("discourse_post_event.upcoming_events_list.error");
viewAllLabel = i18n("discourse_post_event.upcoming_events_list.view_all");

constructor() {
super(...arguments);
this.appEvents.on("page:changed", this, this.updateEventsList);
}

get categoryId() {
return this.router.currentRoute.attributes?.category?.id;
}

get hasEmptyResponse() {
return (
!this.isLoading &&
!this.hasError &&
Object.keys(this.eventsByMonth).length === 0
);
}

get title() {
const categorySlug = this.router.currentRoute.attributes?.category?.slug;
const titleSetting = this.siteSettings.map_events_title;

if (titleSetting === "") {
return i18n("discourse_post_event.upcoming_events_list.title");
}

const categories = JSON.parse(titleSetting).map(
({ category_slug }) => category_slug
);

if (categories.includes(categorySlug)) {
const titleMap = JSON.parse(titleSetting);
const customTitleLookup = titleMap.find(
(o) => o.category_slug === categorySlug
);
return customTitleLookup?.custom_title;
} else {
return i18n("discourse_post_event.upcoming_events_list.title");
}
}

@action
async updateEventsList() {
this.isLoading = true;
this.hasError = false;

const data = {
limit: this.count,
before: moment().add(this.upcomingDays, "days").toISOString(),
};

if (this.categoryId) {
data.category_id = this.categoryId;
}

try {
const { events } = await ajax("/discourse-post-event/events", {
data,
});

this.eventsByMonth = this.groupByMonthAndDay(events);
} catch {
this.hasError = true;
} finally {
this.isLoading = false;
}
}

@action
formatTime({ starts_at, ends_at }) {
return isNotFullDayEvent(moment(starts_at), moment(ends_at))
? moment(starts_at).format(this.timeFormat)
: this.allDayLabel;
}

@action
startsAtMonth(month, day) {
return moment(`${month}-${day}`).format("MMM");
}

@action
startsAtDay(month, day) {
return moment(`${month}-${day}`).format("D");
}

groupByMonthAndDay(data) {
return data.reduce((result, item) => {
const startDate = moment(item.starts_at);
const endDate = item.ends_at ? moment(item.ends_at) : null;
const today = moment();

if (!endDate) {
addToResult(startDate, item, result);
return result;
}

while (startDate.isSameOrBefore(endDate, "day")) {
if (startDate.isAfter(today)) {
addToResult(startDate, item, result);
}

startDate.add(1, "day");
}

return result;
}, {});
}

<template>
<div class="upcoming-events-list">
<h3 class="upcoming-events-list__heading">
{{this.title}}
</h3>

<div class="upcoming-events-list__container">
<ConditionalLoadingSpinner @condition={{this.isLoading}} />

{{#if this.hasEmptyResponse}}
<div class="upcoming-events-list__empty-message">
{{this.emptyMessage}}
</div>
{{/if}}

{{#if this.hasError}}
<div class="upcoming-events-list__error-message">
{{this.errorMessage}}
</div>
<DButton
@action={{this.updateEventsList}}
@label="discourse_post_event.upcoming_events_list.try_again"
class="btn-link upcoming-events-list__try-again"
/>
{{/if}}

{{#unless this.isLoading}}
<PluginOutlet @name="upcoming-events-list-container">
{{#each-in this.eventsByMonth as |month monthData|}}
{{#each-in monthData as |day events|}}
{{#each events as |event|}}
<a
class="upcoming-events-list__event"
href={{event.post.url}}
>
<div class="upcoming-events-list__event-date">
<div class="month">{{this.startsAtMonth month day}}</div>
<div class="day">{{this.startsAtDay month day}}</div>
</div>
<div class="upcoming-events-list__event-content">
<span
class="upcoming-events-list__event-name"
title={{or event.name event.post.topic.title}}
>
{{or event.name event.post.topic.title}}
</span>
{{#if this.timeFormat}}
<span class="upcoming-events-list__event-time">
{{this.formatTime event}}
</span>
{{/if}}
</div>
</a>
{{/each}}
{{/each-in}}
{{/each-in}}
</PluginOutlet>
{{/unless}}
</div>

<div class="upcoming-events-list__footer">
<LinkTo
@route="discourse-post-event-upcoming-events"
class="upcoming-events-list__view-all"
>
{{this.viewAllLabel}}
</LinkTo>
</div>
</div>
</template>
}

View file

@ -0,0 +1,13 @@
import Component from "@glimmer/component";

export default class CategoryCalendar extends Component {
static shouldRender(_, ctx) {
return (
ctx.siteSettings.calendar_categories_outlet === "before-topic-list-body"
);
}

<template>
<div class="before-topic-list-body-outlet category-calendar"></div>
</template>
}

View file

@ -0,0 +1,48 @@
import Component, { Input } from "@ember/component";
import { tagName } from "@ember-decorators/component";
import { or } from "truth-helpers";
import { i18n } from "discourse-i18n";

@tagName("")
export default class ShowEventCategorySortingSettings extends Component {
<template>
{{#if
(or
this.siteSettings.sort_categories_by_event_start_date_enabled
this.siteSettings.disable_resorting_on_categories_enabled
)
}}
<section>
<h3>{{i18n
"discourse_post_event.category.settings_sections.event_sorting"
}}</h3>

{{#if this.siteSettings.sort_categories_by_event_start_date_enabled}}
<section class="field show-subcategory-list-field">
<label>
<Input
@type="checkbox"
@checked={{this.category.custom_fields.sort_topics_by_event_start_date}}
/>
{{i18n
"discourse_post_event.category.sort_topics_by_event_start_date"
}}
</label>
</section>
{{/if}}

{{#if this.siteSettings.disable_resorting_on_categories_enabled}}
<section class="field show-subcategory-list-field">
<label>
<Input
@type="checkbox"
@checked={{this.category.custom_fields.disable_topic_resorting}}
/>
{{i18n "discourse_post_event.category.disable_topic_resorting"}}
</label>
</section>
{{/if}}
</section>
{{/if}}
</template>
}

View file

@ -0,0 +1,14 @@
import Component from "@glimmer/component";

export default class CategoryEventsCalendar extends Component {
static shouldRender(_, ctx) {
return (
ctx.siteSettings.calendar_categories_outlet ===
"discovery-list-container-top"
);
}

<template>
<div id="category-events-calendar"></div>
</template>
}

View file

@ -0,0 +1,7 @@
import EventDate from "../../components/event-date";

const EventDateContainer = <template>
<EventDate @topic={{@outletArgs.topic}} />
</template>;

export default EventDateContainer;

View file

@ -0,0 +1,15 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import EventDate from "../../components/event-date";

export default class EventBadge extends Component {
@service siteSettings;

<template>
{{~#if this.siteSettings.discourse_post_event_enabled~}}
{{~#if @outletArgs.topic.event_starts_at~}}
<EventDate @topic={{@outletArgs.topic}} />
{{~/if~}}
{{~/if~}}
</template>
}

View file

@ -0,0 +1,50 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { i18n } from "discourse-i18n";
import RegionInput from "../../components/region-input";
import { TIME_ZONE_TO_REGION } from "../../lib/regions";

export default class Region extends Component {
static shouldRender(args, component) {
return component.siteSettings.calendar_enabled;
}

@service siteSettings;

@action
onChange(value) {
this.args.outletArgs.model.set("custom_fields.holidays-region", value);
}

@action
useCurrentRegion() {
this.args.outletArgs.model.set(
"custom_fields.holidays-region",
TIME_ZONE_TO_REGION[moment.tz.guess()] || "us"
);
}

<template>
<div class="control-group region">
<label class="control-label">
{{i18n "discourse_calendar.region.title"}}
</label>

<div class="controls">
<RegionInput
@value={{@outletArgs.model.custom_fields.holidays-region}}
@allowNoneRegion={{true}}
@onChange={{this.onChange}}
/>
</div>

<DButton
@icon="globe"
@label="discourse_calendar.region.use_current_region"
@action={{this.useCurrentRegion}}
/>
</div>
</template>
}

View file

@ -0,0 +1,28 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default class AdminPluginsCalendarController extends Controller {
selectedRegion = null;
loading = false;

@action
async getHolidays(region_code) {
if (this.loading) {
return;
}

this.set("selectedRegion", region_code);
this.set("loading", true);

return ajax(
`/admin/discourse-calendar/holiday-regions/${region_code}/holidays`
)
.then((response) => {
this.model.set("holidays", response.holidays);
})
.catch(popupAjaxError)
.finally(() => this.set("loading", false));
}
}

View file

@ -0,0 +1,5 @@
import Controller from "@ember/controller";

export default class DiscoursePostEventUpcomingEventsIndexController extends Controller {
queryParams = ["view"];
}

View file

@ -0,0 +1,5 @@
import Controller from "@ember/controller";

export default class DiscoursePostEventUpcomingEventsMineController extends Controller {
queryParams = ["view"];
}

View file

@ -0,0 +1,10 @@
export default function () {
this.route(
"discourse-post-event-upcoming-events",
{ path: "/upcoming-events" },
function () {
this.route("index", { path: "/" });
this.route("mine");
}
);
}

View file

@ -0,0 +1,28 @@
import { i18n } from "discourse-i18n";

function sameTimezoneOffset(timezone1, timezone2) {
if (!timezone1 || !timezone2) {
return false;
}

const offset1 = moment.tz(timezone1).utcOffset();
const offset2 = moment.tz(timezone2).utcOffset();
return offset1 === offset2;
}

export function formatEventName(event, userTimezone) {
let output = event.name || event.post.topic.title;

if (
event.showLocalTime &&
event.timezone &&
!sameTimezoneOffset(event.timezone, userTimezone)
) {
output +=
` (${i18n("discourse_calendar.local_time")}: ` +
moment(event.startsAt).tz(event.timezone).format("H:mma") +
")";
}

return output;
}

View file

@ -0,0 +1,8 @@
import { htmlSafe } from "@ember/template";
import guessDateFormat from "../lib/guess-best-date-format";

export default function (date) {
date = moment.utc(date).tz(moment.tz.guess());
const format = guessDateFormat(date);
return htmlSafe(date.format(format));
}

View file

@ -0,0 +1,51 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import DiscoursePostEventEvent from "discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event";
import PostEventBuilder from "../components/modal/post-event-builder";

function initializeEventBuilder(api) {
const currentUser = api.getCurrentUser();
const modal = api.container.lookup("service:modal");

api.addComposerToolbarPopupMenuOption({
action: (toolbarEvent) => {
const event = DiscoursePostEventEvent.create({
status: "public",
starts_at: moment(),
timezone: moment.tz.guess(),
});

modal.show(PostEventBuilder, {
model: { event, toolbarEvent },
});
},
group: "insertions",
icon: "calendar-day",
label: "discourse_post_event.builder_modal.attach",
condition: (composer) => {
if (!currentUser || !currentUser.can_create_discourse_post_event) {
return false;
}

const composerModel = composer.model;
return (
composerModel &&
!composerModel.replyingToTopic &&
(composerModel.topicFirstPost ||
composerModel.creatingPrivateMessage ||
(composerModel.editingPost &&
composerModel.post &&
composerModel.post.post_number === 1))
);
},
});
}

export default {
name: "add-post-event-builder",
initialize(container) {
const siteSettings = container.lookup("service:site-settings");
if (siteSettings.discourse_post_event_enabled) {
withPluginApi("0.8.7", initializeEventBuilder);
}
},
};

View file

@ -0,0 +1,26 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { i18n } from "discourse-i18n";

export default {
name: "add-upcoming-events-to-sidebar",

initialize(container) {
const siteSettings = container.lookup("service:site-settings");
if (
siteSettings.discourse_post_event_enabled &&
siteSettings.sidebar_show_upcoming_events
) {
withPluginApi("0.8.7", (api) => {
api.addCommunitySectionLink((baseSectionLink) => {
return class UpcomingEventsSectionLink extends baseSectionLink {
name = "upcoming-events";
route = "discourse-post-event-upcoming-events";
text = i18n("discourse_post_event.upcoming_events.title");
title = i18n("discourse_post_event.upcoming_events.title");
defaultPrefixValue = "calendar-day";
};
});
});
}
},
};

View file

@ -0,0 +1,24 @@
import { withPluginApi } from "discourse/lib/plugin-api";

export default {
name: "disable-sort",

initialize(container) {
withPluginApi("0.8", (api) => {
api.registerValueTransformer(
"topic-list-header-sortable-column",
({ value, context }) => {
if (!value) {
return value;
}

const siteSettings = container.lookup("service:site-settings");
return !(
siteSettings.disable_resorting_on_categories_enabled &&
context.category?.custom_fields?.disable_topic_resorting
);
}
);
});
},
};

View file

@ -0,0 +1,159 @@
import { isTesting } from "discourse/lib/environment";
import { withPluginApi } from "discourse/lib/plugin-api";
import I18n, { i18n } from "discourse-i18n";
import DiscoursePostEvent from "discourse/plugins/discourse-calendar/discourse/components/discourse-post-event";
import DiscoursePostEventEvent from "discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event";
import guessDateFormat from "../lib/guess-best-date-format";

export function buildEventPreview(eventContainer) {
eventContainer.innerHTML = "";
eventContainer.classList.add("discourse-post-event-preview");

const statusLocaleKey = `discourse_post_event.models.event.status.${
eventContainer.dataset.status || "public"
}.title`;
if (I18n.lookup(statusLocaleKey, { locale: "en" })) {
const statusContainer = document.createElement("div");
statusContainer.classList.add("event-preview-status");
statusContainer.innerText = i18n(statusLocaleKey);
eventContainer.appendChild(statusContainer);
}

const datesContainer = document.createElement("div");
datesContainer.classList.add("event-preview-dates");

const startsAt = moment.tz(
eventContainer.dataset.start,
eventContainer.dataset.timezone || "UTC"
);

const endsAt =
eventContainer.dataset.end &&
moment.tz(
eventContainer.dataset.end,
eventContainer.dataset.timezone || "UTC"
);

const format = guessDateFormat(startsAt, endsAt);
const guessedTz = isTesting() ? "UTC" : moment.tz.guess();

let datesString = `<span class='start'>${startsAt
.tz(guessedTz)
.format(format)}</span>`;
if (endsAt) {
datesString += ` → <span class='end'>${endsAt
.tz(guessedTz)
.format(format)}</span>`;
}
datesContainer.innerHTML = datesString;

eventContainer.appendChild(datesContainer);
}

function _invalidEventPreview(eventContainer) {
eventContainer.classList.add(
"discourse-post-event-preview",
"alert",
"alert-error"
);
eventContainer.classList.remove("discourse-post-event");
eventContainer.innerText = i18n(
"discourse_post_event.preview.more_than_one_event"
);
}

function _decorateEventPreview(api, cooked) {
const eventContainers = cooked.querySelectorAll(".discourse-post-event");

eventContainers.forEach((eventContainer, index) => {
if (index > 0) {
_invalidEventPreview(eventContainer);
} else {
buildEventPreview(eventContainer);
}
});
}

function initializeDiscoursePostEventDecorator(api) {
api.decorateCookedElement(
(cooked, helper) => {
if (cooked.classList.contains("d-editor-preview")) {
_decorateEventPreview(api, cooked);
return;
}

if (helper) {
const post = helper.getModel();

if (!post?.event) {
return;
}

const postEventNode = cooked.querySelector(".discourse-post-event");

if (!postEventNode) {
return;
}

const wrapper = document.createElement("div");
postEventNode.before(wrapper);

const event = DiscoursePostEventEvent.create(post.event);

helper.renderGlimmer(
wrapper,
<template><DiscoursePostEvent @event={{event}} /></template>
);
}
},
{
id: "discourse-post-event-decorator",
}
);

api.replaceIcon(
"notification.discourse_post_event.notifications.invite_user_notification",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_post_event.notifications.invite_user_auto_notification",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_calendar.invite_user_notification",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_post_event.notifications.invite_user_predefined_attendance_notification",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_post_event.notifications.before_event_reminder",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_post_event.notifications.after_event_reminder",
"calendar-day"
);

api.replaceIcon(
"notification.discourse_post_event.notifications.ongoing_event_reminder",
"calendar-day"
);
}

export default {
name: "discourse-post-event-decorator",

initialize(container) {
const siteSettings = container.lookup("service:site-settings");
if (siteSettings.discourse_post_event_enabled) {
withPluginApi("0.8.7", initializeDiscoursePostEventDecorator);
}
},
};

View file

@ -0,0 +1,38 @@
import { cancel } from "@ember/runloop";
import { isTesting } from "discourse/lib/environment";
import discourseLater from "discourse/lib/later";
import eventRelativeDate from "../lib/event-relative-date";

function computeRelativeEventDates() {
document
.querySelectorAll(".event-relative-date.topic-list")
.forEach((dateContainer) => eventRelativeDate(dateContainer));
}

export default {
name: "event-future-date",

initialize() {
computeRelativeEventDates();

if (!isTesting()) {
this._tick();
}
},

teardown() {
if (this._interval) {
cancel(this._interval);
this._interval = null;
}
},

_tick() {
this._interval && cancel(this._interval);

this._interval = discourseLater(() => {
computeRelativeEventDates();
this._tick();
}, 60 * 1000);
},
};

View file

@ -0,0 +1,28 @@
/* eslint-disable no-console */
import DiscoursePostEventEvent from "../models/discourse-post-event-event";

export default function addRecurrentEvents(events) {
try {
return events.flatMap((event) => {
if (!event.upcomingDates?.length) {
return [event];
}

const upcomingEvents =
event.upcomingDates?.map((upcomingDate) =>
DiscoursePostEventEvent.create({
name: event.name,
post: event.post,
category_id: event.categoryId,
starts_at: upcomingDate.starts_at,
ends_at: upcomingDate.ends_at,
})
) || [];

return upcomingEvents;
});
} catch (error) {
console.error("Failed to retrieve events:", error);
return [];
}
}

View file

@ -0,0 +1,15 @@
import I18n, { i18n } from "discourse-i18n";

export function getCurrentBcp47Locale() {
return I18n.currentLocale().replace("_", "-").toLowerCase();
}

export function getCalendarButtonsText() {
return {
today: i18n("discourse_calendar.toolbar_button.today"),
month: i18n("discourse_calendar.toolbar_button.month"),
week: i18n("discourse_calendar.toolbar_button.week"),
day: i18n("discourse_calendar.toolbar_button.day"),
list: i18n("discourse_calendar.toolbar_button.list"),
};
}

View file

@ -0,0 +1,28 @@
// https://stackoverflow.com/a/16348977
export function stringToColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
// eslint-disable-next-line no-bitwise
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = [];
for (let i = 0; i < 3; i++) {
// eslint-disable-next-line no-bitwise
let value = (hash >> (i * 8)) & 0xff;
color.push(value);
}
return color;
}

export function colorToHex(color) {
let hex = "#";
for (let i = 0; i < 3; i++) {
hex += ("00" + Math.round(color[i]).toString(16)).slice(-2);
}
return hex;
}

export function contrastColor(color) {
const luminance = 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2];
return luminance / 255 >= 0.5 ? "#000d" : "#fffd";
}

View file

@ -0,0 +1,127 @@
const calendarRule = {
tag: "calendar",

before: function (state, info) {
let wrapperDivToken = state.push("div_calendar_wrap", "div", 1);
wrapperDivToken.attrs = [["class", "discourse-calendar-wrap"]];

let headerDivToken = state.push("div_calendar_header", "div", 1);
headerDivToken.attrs = [["class", "discourse-calendar-header"]];

let titleH2Token = state.push("h2_open", "h2", 1);
titleH2Token.attrs = [["class", "discourse-calendar-title"]];
state.push("h2_close", "h2", -1);

let timezoneWrapToken = state.push("span_open", "span", 1);
timezoneWrapToken.attrs = [["class", "discourse-calendar-timezone-wrap"]];
if (info.attrs.tzPicker === "true") {
_renderTimezonePicker(state);
}
state.push("span_close", "span", -1);

state.push("div_calendar_header", "div", -1);

let mainCalendarDivToken = state.push("div_calendar", "div", 1);
mainCalendarDivToken.attrs = [
["class", "calendar"],
["data-calendar-type", info.attrs.type || "dynamic"],
["data-calendar-default-timezone", info.attrs.defaultTimezone],
];

if (info.attrs.defaultView) {
mainCalendarDivToken.attrs.push([
"data-calendar-default-view",
info.attrs.defaultView,
]);
}

if (info.attrs.weekends) {
mainCalendarDivToken.attrs.push(["data-weekends", info.attrs.weekends]);
}

if (info.attrs.showAddToCalendar) {
mainCalendarDivToken.attrs.push([
"data-calendar-show-add-to-calendar",
info.attrs.showAddToCalendar === "true",
]);
}

if (info.attrs.fullDay) {
mainCalendarDivToken.attrs.push([
"data-calendar-full-day",
info.attrs.fullDay === "true",
]);
}

if (info.attrs.hiddenDays) {
mainCalendarDivToken.attrs.push([
"data-hidden-days",
info.attrs.hiddenDays,
]);
}
},

after: function (state) {
state.push("div_calendar", "div", -1);
state.push("div_calendar_wrap", "div", -1);
},
};

const groupTimezoneRule = {
tag: "timezones",

before: function (state, info) {
const wrapperDivToken = state.push("div_group_timezones", "div", 1);
wrapperDivToken.attrs = [
["class", "group-timezones"],
["data-group", info.attrs.group],
["data-size", info.attrs.size || "medium"],
];
},

after: function (state) {
state.push("div_group_timezones", "div", -1);
},
};

function _renderTimezonePicker(state) {
const timezoneSelectToken = state.push("select_open", "select", 1);
timezoneSelectToken.attrs = [["class", "discourse-calendar-timezone-picker"]];

state.push("select_close", "select", -1);
}

export function setup(helper) {
helper.allowList([
"div.calendar",
"div.discourse-calendar-header",
"div.discourse-calendar-wrap",
"select.discourse-calendar-timezone-picker",
"span.discourse-calendar-timezone-wrap",
"h2.discourse-calendar-title",
"div[data-calendar-type]",
"div[data-calendar-default-view]",
"div[data-calendar-default-timezone]",
"div[data-weekends]",
"div[data-hidden-days]",
"div.group-timezones",
"div[data-group]",
"div[data-size]",
]);

helper.registerOptions((opts, siteSettings) => {
opts.features["discourse-calendar-enabled"] =
!!siteSettings.calendar_enabled;
});

helper.registerPlugin((md) => {
const features = md.options.discourse.features;
if (features["discourse-calendar-enabled"]) {
md.block.bbcode.ruler.push("discourse-calendar", calendarRule);
md.block.bbcode.ruler.push(
"discourse-group-timezones",
groupTimezoneRule
);
}
});
}

View file

@ -0,0 +1,41 @@
const rule = {
tag: "event",

wrap(token, info) {
if (!info.attrs.start) {
return false;
}

token.attrs = [["class", "discourse-post-event"]];

Object.keys(info.attrs).forEach((key) => {
const value = info.attrs[key];

if (typeof value !== "undefined") {
token.attrs.push([`data-${dasherize(key)}`, value]);
}
});

return true;
},
};

function dasherize(input) {
return input.replace(/[A-Z]/g, function (char, index) {
return (index !== 0 ? "-" : "") + char.toLowerCase();
});
}

export function setup(helper) {
helper.allowList(["div.discourse-post-event"]);

helper.registerOptions((opts, siteSettings) => {
opts.features.discourse_post_event =
siteSettings.calendar_enabled &&
siteSettings.discourse_post_event_enabled;
});

helper.registerPlugin((md) =>
md.block.bbcode.ruler.push("discourse-post-event", rule)
);
}

View file

@ -0,0 +1,58 @@
import { i18n } from "discourse-i18n";
import guessDateFormat from "./guess-best-date-format";

function _computeCurrentEvent(container, endsAt) {
const indicator = document.createElement("div");
indicator.classList.add("indicator");
container.appendChild(indicator);

const text = document.createElement("span");
text.classList.add("text");
text.innerText = i18n("discourse_post_event.topic_title.ends_in_duration", {
duration: endsAt.from(moment()),
});
container.appendChild(text);
}

function _computePastEvent(container, endsAt) {
container.innerText = endsAt.from(moment());
}

function _computeFutureEvent(container, startsAt) {
container.innerText = startsAt.from(moment());
}

export default function eventRelativeDate(container) {
container.classList.remove("past", "current", "future");
container.innerHTML = "";

const startsAt = moment
.utc(container.dataset.starts_at)
.tz(moment.tz.guess());
const endsAt = moment.utc(container.dataset.ends_at).tz(moment.tz.guess());

const format = guessDateFormat(startsAt);
let title = startsAt.format(format);
if (endsAt) {
title += ` → ${endsAt.format(format)}`;
}
container.setAttribute("title", title);

if (startsAt.isAfter(moment()) && endsAt.isAfter(moment())) {
container.classList.add("future");
_computeFutureEvent(container, startsAt);
return;
}

if (startsAt.isBefore(moment()) && endsAt.isAfter(moment())) {
container.classList.add("current");
_computeCurrentEvent(container, endsAt);
return;
}

if (startsAt.isBefore(moment()) && endsAt.isBefore(moment())) {
container.classList.add("past");
_computePastEvent(container, endsAt);
return;
}
}

View file

@ -0,0 +1,25 @@
import { escape } from "pretty-text/sanitizer";
import {
getCalendarButtonsText,
getCurrentBcp47Locale,
} from "./calendar-locale";
import { buildPopover, destroyPopover } from "./popover";

export default function fullCalendarDefaultOptions() {
return {
eventClick: function () {
destroyPopover();
},
locale: getCurrentBcp47Locale(),
buttonText: getCalendarButtonsText(),
eventMouseEnter: function ({ event, jsEvent }) {
destroyPopover();

const htmlContent = escape(event.title);
buildPopover(jsEvent, htmlContent);
},
eventMouseLeave: function () {
destroyPopover();
},
};
}

View file

@ -0,0 +1,18 @@
export function isNotFullDayEvent(startsAt, endsAt) {
return (
startsAt.hours() > 0 ||
startsAt.minutes() > 0 ||
(endsAt && (moment(endsAt).hours() > 0 || moment(endsAt).minutes() > 0))
);
}

export default function guessDateFormat(startsAt, endsAt) {
let format;
if (!isNotFullDayEvent(startsAt, endsAt)) {
format = "LL";
} else {
format = "LLL";
}

return format;
}

View file

@ -0,0 +1,39 @@
import { createPopper } from "@popperjs/core";

let eventPopper;
const EVENT_POPOVER_ID = "event-popover";

export function buildPopover(jsEvent, htmlContent) {
const node = document.createElement("div");
node.setAttribute("id", EVENT_POPOVER_ID);
node.innerHTML = htmlContent;

const arrow = document.createElement("span");
arrow.dataset.popperArrow = true;
node.appendChild(arrow);
document.body.appendChild(node);

eventPopper = createPopper(
jsEvent.target,
document.getElementById(EVENT_POPOVER_ID),
{
placement: "bottom",
modifiers: [
{
name: "arrow",
},
{
name: "offset",
options: {
offset: [20, 10],
},
},
],
}
);
}

export function destroyPopover() {
eventPopper?.destroy();
document.getElementById(EVENT_POPOVER_ID)?.remove();
}

View file

@ -0,0 +1,136 @@
export function buildParams(startsAt, endsAt, event, siteSettings) {
const params = {};

const eventTz = event.timezone || "UTC";

params.start = moment(startsAt).tz(eventTz).format("YYYY-MM-DD HH:mm");

if (event.isClosed) {
params.closed = "true";
}

if (event.status) {
params.status = event.status;
}

if (event.name) {
params.name = event.name;
}

if (event.location) {
params.location = event.location;
}

if (event.description) {
params.description = event.description;
}

if (event.url) {
params.url = event.url;
}

if (event.timezone) {
params.timezone = event.timezone;
}

if (event.recurrence) {
params.recurrence = event.recurrence;
}

if (event.recurrenceUntil) {
params.recurrenceUntil = moment(event.recurrenceUntil)
.tz(eventTz)
.format("YYYY-MM-DD HH:mm");
}

if (event.showLocalTime) {
params.showLocalTime = "true";
}

if (event.minimal) {
params.minimal = "true";
}

if (event.chatEnabled) {
params.chatEnabled = "true";
}

if (endsAt) {
params.end = moment(endsAt).tz(eventTz).format("YYYY-MM-DD HH:mm");
}

if (event.status === "private") {
params.allowedGroups = (event.rawInvitees || []).join(",");
}

if (event.status === "public") {
params.allowedGroups = "trust_level_0";
}

if (event.reminders && event.reminders.length) {
params.reminders = event.reminders
.map((r) => {
// we create a new intermediate object to avoid changes in the UI while
// we prepare the values for request
const reminder = Object.assign({}, r);

if (reminder.period === "after") {
reminder.value = `-${Math.abs(parseInt(reminder.value, 10))}`;
}
if (reminder.period === "before") {
reminder.value = Math.abs(parseInt(`${reminder.value}`, 10));
}

return `${reminder.type}.${reminder.value}.${reminder.unit}`;
})
.join(",");
}

siteSettings.discourse_post_event_allowed_custom_fields
.split("|")
.filter(Boolean)
.forEach((setting) => {
const param = camelCase(setting);
if (typeof event.customFields[setting] !== "undefined") {
params[param] = event.customFields[setting];
}
});

return params;
}

export function replaceRaw(params, raw) {
const eventRegex = /\[event (.*?)\](.*?)\[\/event\]/s;
const eventMatches = raw.match(eventRegex);

if (eventMatches && eventMatches[1]) {
const markdownParams = [];

let description = params.description;
description = description ? `${description}\n` : "";
delete params.description;

Object.keys(params).forEach((param) => {
const value = params[param];
if (value && value.length) {
markdownParams.push(`${param}="${value.replace(/"/g, "")}"`);
}
});

return raw.replace(
eventRegex,
`[event ${markdownParams.join(" ")}]\n${description}[/event]`
);
}

return false;
}

function camelCase(input) {
return input
.toLowerCase()
.replace(/-/g, "_")
.replace(/_(.)/g, function (match, group1) {
return group1.toUpperCase();
});
}

View file

@ -0,0 +1,481 @@
// DO NOT EDIT THIS FILE!!!
// Update it by running `rake javascript:update_constants`

export const HOLIDAY_REGIONS = [
"ae",
"ar",
"at",
"au",
"au_nsw",
"au_vic",
"au_qld",
"au_nt",
"au_act",
"au_sa",
"au_wa",
"au_tas",
"au_tas_south",
"au_qld_cairns",
"au_qld_brisbane",
"au_tas_north",
"au_vic_melbourne",
"be_fr",
"be_nl",
"br",
"br_spcapital",
"br_sp",
"bg_en",
"bg_bg",
"ca",
"ca_qc",
"ca_ab",
"ca_sk",
"ca_on",
"ca_bc",
"ca_nb",
"ca_mb",
"ca_ns",
"ca_pe",
"ca_nl",
"ca_nt",
"ca_nu",
"ca_yt",
"us",
"ch_zh",
"ch_be",
"ch_lu",
"ch_ur",
"ch_sz",
"ch_ow",
"ch_nw",
"ch_gl",
"ch_zg",
"ch_fr",
"ch_so",
"ch_bs",
"ch_bl",
"ch_sh",
"ch_ar",
"ch_ai",
"ch_sg",
"ch_gr",
"ch_ag",
"ch_tg",
"ch_ti",
"ch_vd",
"ch_ne",
"ch_ge",
"ch_ju",
"ch_vs",
"ch",
"cl",
"co",
"cr",
"cz",
"dk",
"de",
"de_bw",
"de_by",
"de_he",
"de_nw",
"de_rp",
"de_sl",
"de_sn_sorbian",
"de_th_cath",
"de_sn",
"de_st",
"de_be",
"de_by_cath",
"de_by_augsburg",
"de_bb",
"de_mv",
"de_th",
"de_hb",
"de_hh",
"de_ni",
"de_sh",
"ee",
"el",
"es_pv",
"es_na",
"es_an",
"es_ib",
"es_cm",
"es_mu",
"es_m",
"es_ar",
"es_cl",
"es_cn",
"es_lo",
"es_ga",
"es_ce",
"es_o",
"es_ex",
"es",
"es_ct",
"es_v",
"es_vc",
"fi",
"fr_a",
"fr_m",
"fr",
"gb",
"gb_eng",
"gb_wls",
"gb_eaw",
"gb_nir",
"je",
"gb_jsy",
"gg",
"gb_gsy",
"gb_sct",
"gb_con",
"im",
"gb_iom",
"ge",
"gh",
"hr",
"hk",
"hu",
"id",
"ie",
"in",
"in_mh",
"in_gj",
"in_ka",
"in_tn",
"is",
"it",
"it_ve",
"it_tv",
"it_vr",
"it_pd",
"it_fi",
"it_ge",
"it_to",
"it_rm",
"it_vi",
"it_bl",
"it_ro",
"kr",
"kz",
"li",
"lt",
"lv",
"ma",
"mt_mt",
"mt_en",
"mx",
"mx_pue",
"nl",
"lu",
"no",
"nz",
"nz_sl",
"nz_we",
"nz_ak",
"nz_nl",
"nz_ne",
"nz_ot",
"nz_ta",
"nz_sc",
"nz_hb",
"nz_mb",
"nz_ca",
"nz_ch",
"nz_wl",
"pe",
"ph",
"pl",
"pt",
"pt_li",
"pt_po",
"ro",
"rs_cyrl",
"rs_la",
"ru",
"se",
"sa",
"tn",
"tr",
"ua",
"us_fl",
"us_la",
"us_ct",
"us_de",
"us_gu",
"us_hi",
"us_in",
"us_ky",
"us_nj",
"us_nc",
"us_nd",
"us_pr",
"us_tn",
"us_ms",
"us_id",
"us_ar",
"us_tx",
"us_dc",
"us_md",
"us_va",
"us_vt",
"us_ak",
"us_ca",
"us_me",
"us_ma",
"us_al",
"us_ga",
"us_ne",
"us_mo",
"us_sc",
"us_wv",
"us_vi",
"us_ut",
"us_ri",
"us_az",
"us_co",
"us_oh",
"us_or",
"us_sd",
"us_wy",
"us_nv",
"us_mt",
"us_ny",
"us_pa",
"us_nm",
"us_ia",
"us_il",
"us_ks",
"us_mi",
"us_mn",
"us_nh",
"us_ok",
"us_wa",
"us_wi",
"za",
"ve",
"sk",
"si",
"jp",
"vi",
"sg",
"my",
"th",
"ng",
"ke",
"zw",
];

export const TIME_ZONE_TO_REGION = {
"Africa/Accra": "gh",
"Africa/Casablanca": "ma",
"Africa/Ceuta": "es",
"Africa/Harare": "zw",
"Africa/Johannesburg": "za",
"Africa/Lagos": "ng",
"Africa/Nairobi": "ke",
"Africa/Tunis": "tn",
"America/Adak": "us",
"America/Anchorage": "us",
"America/Araguaina": "br",
"America/Argentina/Buenos_Aires": "ar",
"America/Argentina/Catamarca": "ar",
"America/Argentina/Cordoba": "ar",
"America/Argentina/Jujuy": "ar",
"America/Argentina/La_Rioja": "ar",
"America/Argentina/Mendoza": "ar",
"America/Argentina/Rio_Gallegos": "ar",
"America/Argentina/Salta": "ar",
"America/Argentina/San_Juan": "ar",
"America/Argentina/San_Luis": "ar",
"America/Argentina/Tucuman": "ar",
"America/Argentina/Ushuaia": "ar",
"America/Atikokan": "ca",
"America/Bahia": "br",
"America/Bahia_Banderas": "mx",
"America/Belem": "br",
"America/Blanc-Sablon": "ca",
"America/Boa_Vista": "br",
"America/Bogota": "co",
"America/Boise": "us",
"America/Cambridge_Bay": "ca",
"America/Campo_Grande": "br",
"America/Cancun": "mx",
"America/Caracas": "ve",
"America/Chicago": "us",
"America/Chihuahua": "mx",
"America/Ciudad_Juarez": "mx",
"America/Costa_Rica": "cr",
"America/Coyhaique": "cl",
"America/Creston": "ca",
"America/Cuiaba": "br",
"America/Dawson": "ca",
"America/Dawson_Creek": "ca",
"America/Denver": "us",
"America/Detroit": "us",
"America/Edmonton": "ca",
"America/Eirunepe": "br",
"America/Fort_Nelson": "ca",
"America/Fortaleza": "br",
"America/Glace_Bay": "ca",
"America/Goose_Bay": "ca",
"America/Halifax": "ca",
"America/Hermosillo": "mx",
"America/Indiana/Indianapolis": "us",
"America/Indiana/Knox": "us",
"America/Indiana/Marengo": "us",
"America/Indiana/Petersburg": "us",
"America/Indiana/Tell_City": "us",
"America/Indiana/Vevay": "us",
"America/Indiana/Vincennes": "us",
"America/Indiana/Winamac": "us",
"America/Inuvik": "ca",
"America/Iqaluit": "ca",
"America/Juneau": "us",
"America/Kentucky/Louisville": "us",
"America/Kentucky/Monticello": "us",
"America/Lima": "pe",
"America/Los_Angeles": "us",
"America/Maceio": "br",
"America/Manaus": "br",
"America/Matamoros": "mx",
"America/Mazatlan": "mx",
"America/Menominee": "us",
"America/Merida": "mx",
"America/Metlakatla": "us",
"America/Mexico_City": "mx",
"America/Moncton": "ca",
"America/Monterrey": "mx",
"America/New_York": "us",
"America/Nome": "us",
"America/Noronha": "br",
"America/North_Dakota/Beulah": "us",
"America/North_Dakota/Center": "us",
"America/North_Dakota/New_Salem": "us",
"America/Ojinaga": "mx",
"America/Phoenix": "us",
"America/Porto_Velho": "br",
"America/Punta_Arenas": "cl",
"America/Rankin_Inlet": "ca",
"America/Recife": "br",
"America/Regina": "ca",
"America/Resolute": "ca",
"America/Rio_Branco": "br",
"America/Santarem": "br",
"America/Santiago": "cl",
"America/Sao_Paulo": "br",
"America/Sitka": "us",
"America/St_Johns": "ca",
"America/St_Thomas": "vi",
"America/Swift_Current": "ca",
"America/Tijuana": "mx",
"America/Toronto": "ca",
"America/Vancouver": "ca",
"America/Whitehorse": "ca",
"America/Winnipeg": "ca",
"America/Yakutat": "us",
"Antarctica/Macquarie": "au",
"Asia/Almaty": "kz",
"Asia/Anadyr": "ru",
"Asia/Aqtau": "kz",
"Asia/Aqtobe": "kz",
"Asia/Atyrau": "kz",
"Asia/Bangkok": "th",
"Asia/Barnaul": "ru",
"Asia/Chita": "ru",
"Asia/Dubai": "ae",
"Asia/Hong_Kong": "hk",
"Asia/Irkutsk": "ru",
"Asia/Jakarta": "id",
"Asia/Jayapura": "id",
"Asia/Kamchatka": "ru",
"Asia/Khandyga": "ru",
"Asia/Kolkata": "in",
"Asia/Krasnoyarsk": "ru",
"Asia/Kuala_Lumpur": "my",
"Asia/Kuching": "my",
"Asia/Magadan": "ru",
"Asia/Makassar": "id",
"Asia/Manila": "ph",
"Asia/Novokuznetsk": "ru",
"Asia/Novosibirsk": "ru",
"Asia/Omsk": "ru",
"Asia/Oral": "kz",
"Asia/Pontianak": "id",
"Asia/Qostanay": "kz",
"Asia/Qyzylorda": "kz",
"Asia/Riyadh": "sa",
"Asia/Sakhalin": "ru",
"Asia/Seoul": "kr",
"Asia/Singapore": "sg",
"Asia/Srednekolymsk": "ru",
"Asia/Tbilisi": "ge",
"Asia/Tokyo": "jp",
"Asia/Tomsk": "ru",
"Asia/Ust-Nera": "ru",
"Asia/Vladivostok": "ru",
"Asia/Yakutsk": "ru",
"Asia/Yekaterinburg": "ru",
"Atlantic/Azores": "pt",
"Atlantic/Canary": "es",
"Atlantic/Madeira": "pt",
"Atlantic/Reykjavik": "is",
"Australia/Adelaide": "au",
"Australia/Brisbane": "au",
"Australia/Broken_Hill": "au",
"Australia/Darwin": "au",
"Australia/Eucla": "au",
"Australia/Hobart": "au",
"Australia/Lindeman": "au",
"Australia/Lord_Howe": "au",
"Australia/Melbourne": "au",
"Australia/Perth": "au",
"Australia/Sydney": "au",
"Europe/Amsterdam": "nl",
"Europe/Astrakhan": "ru",
"Europe/Athens": "el",
"Europe/Berlin": "de",
"Europe/Bratislava": "sk",
"Europe/Bucharest": "ro",
"Europe/Budapest": "hu",
"Europe/Busingen": "de",
"Europe/Copenhagen": "dk",
"Europe/Dublin": "ie",
"Europe/Guernsey": "gg",
"Europe/Helsinki": "fi",
"Europe/Isle_of_Man": "im",
"Europe/Istanbul": "tr",
"Europe/Jersey": "je",
"Europe/Kaliningrad": "ru",
"Europe/Kirov": "ru",
"Europe/Kyiv": "ua",
"Europe/Lisbon": "pt",
"Europe/Ljubljana": "si",
"Europe/London": "gb",
"Europe/Luxembourg": "lu",
"Europe/Madrid": "es",
"Europe/Moscow": "ru",
"Europe/Oslo": "no",
"Europe/Paris": "fr",
"Europe/Prague": "cz",
"Europe/Riga": "lv",
"Europe/Rome": "it",
"Europe/Samara": "ru",
"Europe/Saratov": "ru",
"Europe/Simferopol": "ru",
"Europe/Stockholm": "se",
"Europe/Tallinn": "ee",
"Europe/Ulyanovsk": "ru",
"Europe/Vaduz": "li",
"Europe/Vienna": "at",
"Europe/Vilnius": "lt",
"Europe/Volgograd": "ru",
"Europe/Warsaw": "pl",
"Europe/Zagreb": "hr",
"Europe/Zurich": "ch",
"Pacific/Auckland": "nz",
"Pacific/Chatham": "nz",
"Pacific/Easter": "cl",
"Pacific/Honolulu": "us",
};

View file

@ -0,0 +1,84 @@
// https://github.com/WebDevTmas/moment-round
if (typeof moment.fn.round !== "function") {
moment.fn.round = function (precision, key, direction) {
direction = direction || "round";
let _this = this; //cache of this
let methods = {
hours: { name: "Hours", maxValue: 24 },
minutes: { name: "Minutes", maxValue: 60 },
seconds: { name: "Seconds", maxValue: 60 },
milliseconds: { name: "Milliseconds", maxValue: 1000 },
};
let keys = {
mm: methods.milliseconds.name,
milliseconds: methods.milliseconds.name,
Milliseconds: methods.milliseconds.name,
s: methods.seconds.name,
seconds: methods.seconds.name,
Seconds: methods.seconds.name,
m: methods.minutes.name,
minutes: methods.minutes.name,
Minutes: methods.minutes.name,
H: methods.hours.name,
h: methods.hours.name,
hours: methods.hours.name,
Hours: methods.hours.name,
};
let value = 0;
let rounded = false;
let subRatio = 1;
let maxValue;

// make sure key is plural
if (key.length > 1 && key !== "mm" && key.slice(-1) !== "s") {
key += "s";
}
key = keys[key].toLowerCase();

//control
if (!methods[key]) {
throw new Error(
'The value to round is not valid. Possibles ["hours", "minutes", "seconds", "milliseconds"]'
);
}

let get = "get" + methods[key].name;
let set = "set" + methods[key].name;

for (let k in methods) {
if (k === key) {
value = _this._d[get]();
maxValue = methods[k].maxValue;
rounded = true;
} else if (rounded) {
subRatio *= methods[k].maxValue;
value += _this._d["get" + methods[k].name]() / subRatio;
_this._d["set" + methods[k].name](0);
}
}

value = Math[direction](value / precision) * precision;
value = Math.min(value, maxValue);
_this._d[set](value);

return _this;
};
}

if (typeof moment.fn.ceil !== "function") {
moment.fn.ceil = function (precision, key) {
return this.round(precision, key, "ceil");
};
}

if (typeof moment.fn.floor !== "function") {
moment.fn.floor = function (precision, key) {
return this.round(precision, key, "floor");
};
}

const STEP = 15;

export default function roundTime(date) {
return date.round(STEP, "minutes");
}

View file

@ -0,0 +1,19 @@
import { tracked } from "@glimmer/tracking";

export default class DiscoursePostEventEventStats {
static create(args = {}) {
return new DiscoursePostEventEventStats(args);
}

@tracked going = 0;
@tracked interested = 0;
@tracked invited = 0;
@tracked notGoing = 0;

constructor(args = {}) {
this.going = args.going;
this.invited = args.invited;
this.interested = args.interested;
this.notGoing = args.not_going;
}
}

View file

@ -0,0 +1,204 @@
import { tracked } from "@glimmer/tracking";
import EmberObject from "@ember/object";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { bind } from "discourse/lib/decorators";
import { optionalRequire } from "discourse/lib/utilities";
import User from "discourse/models/user";
import DiscoursePostEventEventStats from "./discourse-post-event-event-stats";
import DiscoursePostEventInvitee from "./discourse-post-event-invitee";

const ChatChannel = optionalRequire(
"discourse/plugins/chat/discourse/models/chat-channel"
);

const DEFAULT_REMINDER = {
type: "notification",
value: 15,
unit: "minutes",
period: "before",
};

export default class DiscoursePostEventEvent {
static create(args = {}) {
return new DiscoursePostEventEvent(args);
}

@tracked title;
@tracked name;
@tracked categoryId;
@tracked startsAt;
@tracked endsAt;
@tracked rawInvitees;
@tracked location;
@tracked url;
@tracked description;
@tracked timezone;
@tracked showLocalTime;
@tracked status;
@tracked post;
@tracked minimal;
@tracked chatEnabled;
@tracked canUpdateAttendance;
@tracked canActOnDiscoursePostEvent;
@tracked shouldDisplayInvitees;
@tracked isClosed;
@tracked isExpired;
@tracked isStandalone;
@tracked recurrenceUntil;
@tracked recurrence;
@tracked recurrenceRule;
@tracked customFields;
@tracked channel;

@tracked _watchingInvitee;
@tracked _sampleInvitees;
@tracked _stats;
@tracked _creator;
@tracked _reminders;

constructor(args = {}) {
this.id = args.id;
this.name = args.name;
this.categoryId = args.category_id;
this.upcomingDates = args.upcoming_dates;
this.startsAt = args.starts_at;
this.endsAt = args.ends_at;
this.rawInvitees = args.raw_invitees;
this.sampleInvitees = args.sample_invitees || [];
this.location = args.location;
this.url = args.url;
this.description = args.description;
this.timezone = args.timezone;
this.showLocalTime = args.show_local_time;
this.status = args.status;
this.creator = args.creator;
this.post = args.post;
this.isClosed = args.is_closed;
this.isExpired = args.is_expired;
this.isStandalone = args.is_standalone;
this.minimal = args.minimal;
this.chatEnabled = args.chat_enabled;
this.recurrenceRule = args.recurrence_rule;
this.recurrence = args.recurrence;
this.recurrenceUntil = args.recurrence_until;
this.canUpdateAttendance = args.can_update_attendance;
this.canActOnDiscoursePostEvent = args.can_act_on_discourse_post_event;
this.shouldDisplayInvitees = args.should_display_invitees;
this.watchingInvitee = args.watching_invitee;
this.stats = args.stats;
this.reminders = args.reminders;
this.customFields = EmberObject.create(args.custom_fields || {});
if (args.channel && ChatChannel) {
this.channel = ChatChannel.create(args.channel);
}
}

get watchingInvitee() {
return this._watchingInvitee;
}

set watchingInvitee(invitee) {
this._watchingInvitee = invitee
? DiscoursePostEventInvitee.create(invitee)
: null;
}

get sampleInvitees() {
return this._sampleInvitees;
}

set sampleInvitees(invitees = []) {
this._sampleInvitees = new TrackedArray(
invitees.map((i) => DiscoursePostEventInvitee.create(i))
);
}

get stats() {
return this._stats;
}

set stats(stats) {
this._stats = this.#initStatsModel(stats);
}

get reminders() {
return this._reminders;
}

set reminders(reminders = []) {
this._reminders = new TrackedArray(reminders);
}

get creator() {
return this._creator;
}

set creator(user) {
this._creator = this.#initUserModel(user);
}

get isPublic() {
return this.status === "public";
}

get isPrivate() {
return this.status === "private";
}

updateFromEvent(event) {
this.name = event.name;
this.startsAt = event.startsAt;
this.endsAt = event.endsAt;
this.location = event.location;
this.url = event.url;
this.timezone = event.timezone;
this.showLocalTime = event.showLocalTime;
this.description = event.description;
this.status = event.status;
this.creator = event.creator;
this.isClosed = event.isClosed;
this.isExpired = event.isExpired;
this.isStandalone = event.isStandalone;
this.minimal = event.minimal;
this.chatEnabled = event.chatEnabled;
this.recurrenceRule = event.recurrenceRule;
this.recurrence = event.recurrence;
this.recurrenceUntil = event.recurrenceUntil;
this.canUpdateAttendance = event.canUpdateAttendance;
this.canActOnDiscoursePostEvent = event.canActOnDiscoursePostEvent;
this.shouldDisplayInvitees = event.shouldDisplayInvitees;
this.stats = event.stats;
this.sampleInvitees = event.sampleInvitees || [];
this.reminders = event.reminders;
}

@bind
removeReminder(reminder) {
const index = this.reminders.findIndex((r) => r.id === reminder.id);
if (index > -1) {
this.reminders.splice(index, 1);
}
}

@bind
addReminder(reminder) {
reminder ??= { ...DEFAULT_REMINDER };
this.reminders.push(reminder);
}

#initUserModel(user) {
if (!user || user instanceof User) {
return user;
}

return User.create(user);
}

#initStatsModel(stats) {
if (!stats || stats instanceof DiscoursePostEventEventStats) {
return stats;
}

return DiscoursePostEventEventStats.create(stats);
}
}

View file

@ -0,0 +1,25 @@
import { tracked } from "@glimmer/tracking";
import User from "discourse/models/user";

export default class DiscoursePostEventInvitee {
static create(args = {}) {
return new DiscoursePostEventInvitee(args);
}

@tracked status;

constructor(args = {}) {
this.id = args.id;
this.post_id = args.post_id;
this.status = args.status;
this.user = this.#initUserModel(args.user);
}

#initUserModel(user) {
if (!user || user instanceof User) {
return user;
}

return User.create(user);
}
}

View file

@ -0,0 +1,50 @@
import { tracked } from "@glimmer/tracking";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import User from "discourse/models/user";
import DiscoursePostEventInvitee from "./discourse-post-event-invitee";

export default class DiscoursePostEventInvitees {
static create(args = {}) {
return new DiscoursePostEventInvitees(args);
}

@tracked _invitees;
@tracked _suggestedUsers;

constructor(args = {}) {
this.invitees = args.invitees || [];
this.suggestedUsers = args.meta?.suggested_users || [];
}

get invitees() {
return this._invitees;
}

set invitees(invitees = []) {
this._invitees = new TrackedArray(
invitees.map((i) => DiscoursePostEventInvitee.create(i))
);
}

get suggestedUsers() {
return this._suggestedUsers;
}

set suggestedUsers(suggestedUsers = []) {
this._suggestedUsers = new TrackedArray(
suggestedUsers.map((su) => User.create(su))
);
}

add(invitee) {
this.invitees.push(invitee);

this.suggestedUsers = this.suggestedUsers.filter(
(su) => su.id !== invitee.user.id
);
}

remove(invitee) {
this.invitees = this.invitees.filter((i) => i.user.id !== invitee.user.id);
}
}

View file

@ -0,0 +1,9 @@
import RestModel from "discourse/models/rest";

export default class DiscoursePostEventReminder extends RestModel {
init() {
super.init(...arguments);

this.__type = "discourse-post-event-reminder";
}
}

View file

@ -0,0 +1,105 @@
import { camelize } from "@ember/string";
import { withPluginApi } from "discourse/lib/plugin-api";
import { buildEventPreview } from "../initializers/discourse-post-event-decorator";

const EVENT_ATTRIBUTES = {
name: { default: null },
start: { default: null },
end: { default: null },
reminders: { default: null },
minimal: { default: null },
closed: { default: null },
status: { default: "public" },
timezone: { default: "UTC" },
showLocalTime: { default: null },
allowedGroups: { default: null },
recurrence: { default: null },
recurrenceUntil: { default: null },
chatEnabled: { default: null },
chatChannelId: { default: null },
};

/** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
event: {
attrs: EVENT_ATTRIBUTES,
group: "block",
defining: true,
isolating: true,
draggable: true,
parseDOM: [
{
tag: "div.discourse-post-event",
getAttrs(dom) {
return { ...dom.dataset };
},
},
],
toDOM(node) {
const element = document.createElement("div");
element.classList.add("discourse-post-event");
for (const [key, value] of Object.entries(node.attrs)) {
if (value !== null) {
element.dataset[key] = value;
}
}

buildEventPreview(element);

return element;
},
},
},

parse: {
wrap_bbcode(state, token) {
if (token.tag === "div") {
if (token.nesting === -1 && state.top().type.name === "event") {
state.closeNode();
return true;
}

if (
token.nesting === 1 &&
token.attrGet("class") === "discourse-post-event"
) {
const attrs = Object.fromEntries(
token.attrs
.filter(([key]) => key.startsWith("data-"))
.map(([key, value]) => [camelize(key.slice(5)), value])
);

state.openNode(state.schema.nodes.event, attrs);
return true;
}
}

return false;
},
},

serializeNode: {
event(state, node) {
let bbcode = "[event";

Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null) {
bbcode += ` ${key}="${value}"`;
}
});

bbcode += "]\n[/event]\n";

state.write(bbcode);
},
},
};

export default {
initialize() {
withPluginApi("2.1.1", (api) => {
api.registerRichEditorExtension(extension);
});
},
};

View file

@ -0,0 +1,12 @@
import { withPluginApi } from "discourse/lib/plugin-api";

export default {
before: "freeze-valid-transformers",
initialize() {
withPluginApi("1.33.0", (api) => {
api.addValueTransformerName(
"discourse-calendar-event-more-menu-should-show-participants"
);
});
},
};

View file

@ -0,0 +1,19 @@
import { action } from "@ember/object";
import { service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "discourse/routes/discourse";

export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute {
@service discoursePostEventService;

@action
activate() {
if (!this.siteSettings.discourse_post_event_enabled) {
DiscourseURL.redirectTo("/404");
}
}

async model(params) {
return await this.discoursePostEventService.fetchEvents(params);
}
}

View file

@ -0,0 +1,22 @@
import { action } from "@ember/object";
import { service } from "@ember/service";
import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "discourse/routes/discourse";

export default class PostEventUpcomingEventsIndexRoute extends DiscourseRoute {
@service discoursePostEventApi;
@service discoursePostEventService;
@service currentUser;

@action
activate() {
if (!this.siteSettings.discourse_post_event_enabled) {
DiscourseURL.redirectTo("/404");
}
}

async model(params) {
params.attending_user = this.currentUser?.username;
return await this.discoursePostEventService.fetchEvents(params);
}
}

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