diff --git a/.github/labeler.yml b/.github/labeler.yml index a37a4a806d4..bd86fe18f50 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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/**/* diff --git a/.gitignore b/.gitignore index fda4321d2c8..57770038dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.streerc b/.streerc index 5c477379e17..a286bcf70a6 100644 --- a/.streerc +++ b/.streerc @@ -1,2 +1,3 @@ --print-width=100 --plugins=plugin/trailing_comma,plugin/disable_auto_ternary +--ignore-files=plugins/discourse-calendar/vendor/* diff --git a/plugins/discourse-calendar/.prettierignore b/plugins/discourse-calendar/.prettierignore new file mode 100644 index 00000000000..f8f98305742 --- /dev/null +++ b/plugins/discourse-calendar/.prettierignore @@ -0,0 +1,2 @@ +assets/stylesheets/vendor/*.scss +public/ diff --git a/plugins/discourse-calendar/README.md b/plugins/discourse-calendar/README.md new file mode 100644 index 00000000000..e5e5819a166 --- /dev/null +++ b/plugins/discourse-calendar/README.md @@ -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`. diff --git a/plugins/discourse-calendar/app/controllers/admin/discourse_calendar/admin_holidays_controller.rb b/plugins/discourse-calendar/app/controllers/admin/discourse_calendar/admin_holidays_controller.rb new file mode 100644 index 00000000000..c153c123aa1 --- /dev/null +++ b/plugins/discourse-calendar/app/controllers/admin/discourse_calendar/admin_holidays_controller.rb @@ -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 diff --git a/plugins/discourse-calendar/app/controllers/discourse_post_event/discourse_post_event_controller.rb b/plugins/discourse-calendar/app/controllers/discourse_post_event/discourse_post_event_controller.rb new file mode 100644 index 00000000000..ab6efc05e73 --- /dev/null +++ b/plugins/discourse-calendar/app/controllers/discourse_post_event/discourse_post_event_controller.rb @@ -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 diff --git a/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb b/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb new file mode 100644 index 00000000000..a54b0dd3cd0 --- /dev/null +++ b/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb @@ -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 diff --git a/plugins/discourse-calendar/app/controllers/discourse_post_event/invitees_controller.rb b/plugins/discourse-calendar/app/controllers/discourse_post_event/invitees_controller.rb new file mode 100644 index 00000000000..96bb04da5ce --- /dev/null +++ b/plugins/discourse-calendar/app/controllers/discourse_post_event/invitees_controller.rb @@ -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 diff --git a/plugins/discourse-calendar/app/controllers/discourse_post_event/upcoming_events_controller.rb b/plugins/discourse-calendar/app/controllers/discourse_post_event/upcoming_events_controller.rb new file mode 100644 index 00000000000..9c8e972b02d --- /dev/null +++ b/plugins/discourse-calendar/app/controllers/discourse_post_event/upcoming_events_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module DiscoursePostEvent + class UpcomingEventsController < DiscoursePostEventController + def index + end + end +end diff --git a/plugins/discourse-calendar/app/models/calendar_event.rb b/plugins/discourse-calendar/app/models/calendar_event.rb new file mode 100644 index 00000000000..e97abc5a47f --- /dev/null +++ b/plugins/discourse-calendar/app/models/calendar_event.rb @@ -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) +# diff --git a/plugins/discourse-calendar/app/models/discourse_calendar/disabled_holiday.rb b/plugins/discourse-calendar/app/models/discourse_calendar/disabled_holiday.rb new file mode 100644 index 00000000000..e8407883ed8 --- /dev/null +++ b/plugins/discourse-calendar/app/models/discourse_calendar/disabled_holiday.rb @@ -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) +# diff --git a/plugins/discourse-calendar/app/models/discourse_post_event/event.rb b/plugins/discourse-calendar/app/models/discourse_post_event/event.rb new file mode 100644 index 00000000000..7b690cc0925 --- /dev/null +++ b/plugins/discourse-calendar/app/models/discourse_post_event/event.rb @@ -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 +# diff --git a/plugins/discourse-calendar/app/models/discourse_post_event/event_date.rb b/plugins/discourse-calendar/app/models/discourse_post_event/event_date.rb new file mode 100644 index 00000000000..8f820914a9d --- /dev/null +++ b/plugins/discourse-calendar/app/models/discourse_post_event/event_date.rb @@ -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) +# diff --git a/plugins/discourse-calendar/app/models/discourse_post_event/invitee.rb b/plugins/discourse-calendar/app/models/discourse_post_event/invitee.rb new file mode 100644 index 00000000000..570d38657ee --- /dev/null +++ b/plugins/discourse-calendar/app/models/discourse_post_event/invitee.rb @@ -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 +# diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb new file mode 100644 index 00000000000..ac48921d607 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb @@ -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 diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_stats_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_stats_serializer.rb new file mode 100644 index 00000000000..4e3674df602 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_stats_serializer.rb @@ -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 diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb new file mode 100644 index 00000000000..04940c27280 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb @@ -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 diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_list_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_list_serializer.rb new file mode 100644 index 00000000000..ac4c949d9f8 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_list_serializer.rb @@ -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 diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_serializer.rb new file mode 100644 index 00000000000..c2c6b8cc447 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/invitee_serializer.rb @@ -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 diff --git a/plugins/discourse-calendar/app/serializers/user_timezone_serializer.rb b/plugins/discourse-calendar/app/serializers/user_timezone_serializer.rb new file mode 100644 index 00000000000..f1625229132 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/user_timezone_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UserTimezoneSerializer < BasicUserSerializer + attributes :timezone, :on_holiday + + def on_holiday + @options[:on_holiday] || false + end +end diff --git a/plugins/discourse-calendar/app/services/discourse_calendar/holiday.rb b/plugins/discourse-calendar/app/services/discourse_calendar/holiday.rb new file mode 100644 index 00000000000..acb54346ea1 --- /dev/null +++ b/plugins/discourse-calendar/app/services/discourse_calendar/holiday.rb @@ -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 diff --git a/plugins/discourse-calendar/app/services/discourse_post_event/chat_channel_sync.rb b/plugins/discourse-calendar/app/services/discourse_post_event/chat_channel_sync.rb new file mode 100644 index 00000000000..37f9320a66f --- /dev/null +++ b/plugins/discourse-calendar/app/services/discourse_post_event/chat_channel_sync.rb @@ -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 diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-adapter.js b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-adapter.js new file mode 100644 index 00000000000..455506cf72f --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-adapter.js @@ -0,0 +1,7 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default class DiscoursePostEventAdapter extends RestAdapter { + basePath() { + return "/discourse-post-event/"; + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-event.js b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-event.js new file mode 100644 index 00000000000..6c80730092b --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-event.js @@ -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"; + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-invitee.js b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-invitee.js new file mode 100644 index 00000000000..b4ca774f4ab --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-invitee.js @@ -0,0 +1,7 @@ +import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter"; + +export default class DiscoursePostEventInvitee extends DiscoursePostEventNestedAdapter { + apiNameFor() { + return "invitee"; + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-nested-adapter.js b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-nested-adapter.js new file mode 100644 index 00000000000..891cd5c2fac --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-nested-adapter.js @@ -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); + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-reminder.js b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-reminder.js new file mode 100644 index 00000000000..a0a71c543e7 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/adapters/discourse-post-event-reminder.js @@ -0,0 +1,7 @@ +import DiscoursePostEventNestedAdapter from "./discourse-post-event-nested-adapter"; + +export default class DiscoursePostEventReminder extends DiscoursePostEventNestedAdapter { + apiNameFor() { + return "reminder"; + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/admin-calendar-route-map.js b/plugins/discourse-calendar/assets/javascripts/discourse/admin-calendar-route-map.js new file mode 100644 index 00000000000..5a49b3a5082 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/admin-calendar-route-map.js @@ -0,0 +1,7 @@ +export default { + resource: "admin.adminPlugins", + path: "/plugins", + map() { + this.route("calendar"); + }, +}; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/api-initializers/discourse-group-timezones.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/api-initializers/discourse-group-timezones.gjs new file mode 100644 index 00000000000..e07a8320428 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/api-initializers/discourse-group-timezones.gjs @@ -0,0 +1,35 @@ +import { apiInitializer } from "discourse/lib/api"; +import GroupTimezones from "../components/group-timezones"; + +const GroupTimezonesShim = ; + +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", + }); + }); + }); +}); diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list-item.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list-item.gjs new file mode 100644 index 00000000000..407ad74fcb4 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list-item.gjs @@ -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)); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list.gjs new file mode 100644 index 00000000000..058beed6848 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/admin-holidays-list.gjs @@ -0,0 +1,25 @@ +import { i18n } from "discourse-i18n"; +import AdminHolidaysListItem from "./admin-holidays-list-item"; + +const AdminHolidaysList = ; + +export default AdminHolidaysList; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/bulk-invite-sample-csv-file.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/bulk-invite-sample-csv-file.gjs new file mode 100644 index 00000000000..b18ddd14211 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/bulk-invite-sample-csv-file.gjs @@ -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(); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/csv-uploader.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/csv-uploader.gjs new file mode 100644 index 00000000000..d828f61879d --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/csv-uploader.gjs @@ -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; + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs new file mode 100644 index 00000000000..c1740da8e10 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs @@ -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 = ; + +export default DiscoursePostEventChatChannel; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/creator.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/creator.gjs new file mode 100644 index 00000000000..44dc031fd66 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/creator.gjs @@ -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); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/dates.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/dates.gjs new file mode 100644 index 00000000000..48f2e911030 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/dates.gjs @@ -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("")); + 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); + } + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/description.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/description.gjs new file mode 100644 index 00000000000..c83c1a55dbe --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/description.gjs @@ -0,0 +1,11 @@ +import CookText from "discourse/components/cook-text"; + +const DiscoursePostEventDescription = ; + +export default DiscoursePostEventDescription; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/event-status.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/event-status.gjs new file mode 100644 index 00000000000..c773732f0e9 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/event-status.gjs @@ -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}`; + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/index.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/index.gjs new file mode 100644 index 00000000000..df47d81fd04 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/index.gjs @@ -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 = ; + +const InfoSection = ; + +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; + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitee.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitee.gjs new file mode 100644 index 00000000000..76b08b0bb40 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitee.gjs @@ -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); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitees.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitees.gjs new file mode 100644 index 00000000000..4e6d8377484 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/invitees.gjs @@ -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, + }); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/location.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/location.gjs new file mode 100644 index 00000000000..a0c48ad00e1 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/location.gjs @@ -0,0 +1,14 @@ +import CookText from "discourse/components/cook-text"; +import icon from "discourse/helpers/d-icon"; + +const DiscoursePostEventLocation = ; + +export default DiscoursePostEventLocation; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/more-menu.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/more-menu.gjs new file mode 100644 index 00000000000..ea75d468205 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/more-menu.gjs @@ -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; + }); + } + }); + }, + }); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/status.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/status.gjs new file mode 100644 index 00000000000..191eceec464 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/status.gjs @@ -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); + } + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/url.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/url.gjs new file mode 100644 index 00000000000..57c5f2dba38 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/url.gjs @@ -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}`; + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/event-date.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/event-date.gjs new file mode 100644 index 00000000000..16ed4958b1c --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/event-date.gjs @@ -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; + + + + 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()); + } +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/event-field.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/event-field.gjs new file mode 100644 index 00000000000..ef23a09e14c --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/event-field.gjs @@ -0,0 +1,20 @@ +import { notEq } from "truth-helpers"; +import { i18n } from "discourse-i18n"; + +const EventField = ; + +export default EventField; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/index.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/index.gjs new file mode 100644 index 00000000000..1a1910d030c --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/index.gjs @@ -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; + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/new-day.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/new-day.gjs new file mode 100644 index 00000000000..121c7cb931d --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/new-day.gjs @@ -0,0 +1,16 @@ +import icon from "discourse/helpers/d-icon"; + +const NewDay = ; + +export default NewDay; diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/time-traveller.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/time-traveller.gjs new file mode 100644 index 00000000000..c6f07176d9d --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/time-traveller.gjs @@ -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); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/timezone.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/timezone.gjs new file mode 100644 index 00000000000..2e1f854a511 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/group-timezones/timezone.gjs @@ -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"); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/modal/post-event-builder.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/modal/post-event-builder.gjs new file mode 100644 index 00000000000..f2438c59ea5 --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/modal/post-event-builder.gjs @@ -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, ""); + } + +