--- /dev/null
+# frozen_string_literal: true
+
+class Admin::AnnouncementsController < Admin::BaseController
+ before_action :set_announcements, only: :index
+ before_action :set_announcement, except: [:index, :new, :create]
+
+ def index
+ authorize :announcement, :index?
+ end
+
+ def new
+ authorize :announcement, :create?
+
+ @announcement = Announcement.new
+ end
+
+ def create
+ authorize :announcement, :create?
+
+ @announcement = Announcement.new(resource_params)
+
+ if @announcement.save
+ log_action :create, @announcement
+ redirect_to admin_announcements_path
+ else
+ render :new
+ end
+ end
+
+ def edit
+ authorize :announcement, :update?
+ end
+
+ def update
+ authorize :announcement, :update?
+
+ if @announcement.update(resource_params)
+ log_action :update, @announcement
+ redirect_to admin_announcements_path
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ authorize :announcement, :destroy?
+ @announcement.destroy!
+ log_action :destroy, @announcement
+ redirect_to admin_announcements_path
+ end
+
+ private
+
+ def set_announcements
+ @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
+ end
+
+ def set_announcement
+ @announcement = Announcement.find(params[:id])
+ end
+
+ def filter_params
+ params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS)
+ end
+
+ def resource_params
+ params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
+ end
+end
end
def require_authenticated_user!
- render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
+ render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
end
def require_user!
--- /dev/null
+# frozen_string_literal: true
+
+class Api::V1::Announcements::ReactionsController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
+ before_action :require_user!
+
+ before_action :set_announcement
+ before_action :set_reaction, except: :update
+
+ def update
+ @announcement.announcement_reactions.create!(account: current_account, name: params[:id])
+ render_empty
+ end
+
+ def destroy
+ @reaction.destroy!
+ render_empty
+ end
+
+ private
+
+ def set_reaction
+ @reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id])
+ end
+
+ def set_announcement
+ @announcement = Announcement.published.find(params[:announcement_id])
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class Api::V1::AnnouncementsController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss
+ before_action :require_user!
+ before_action :set_announcements, only: :index
+ before_action :set_announcement, except: :index
+
+ def index
+ render json: @announcements, each_serializer: REST::AnnouncementSerializer
+ end
+
+ def dismiss
+ AnnouncementMute.create!(account: current_account, announcement: @announcement)
+ render_empty
+ end
+
+ private
+
+ def set_announcements
+ @announcements = begin
+ scope = Announcement.published
+
+ scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed)
+
+ scope.chronological
+ end
+ end
+
+ def set_announcement
+ @announcement = Announcement.published.find(params[:id])
+ end
+end
log.recorded_changes.slice('severity', 'reject_media')
elsif log.target_type == 'Status' && log.action == :update
log.recorded_changes.slice('sensitive')
+ elsif log.target_type == 'Announcement' && log.action == :update
+ log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
end
end
'pencil'
when 'AccountWarning'
'warning'
+ when 'Announcement'
+ 'bullhorn'
end
end
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id)
+ when 'Announcement'
+ link_to "##{record.id}", edit_admin_announcement_path(record.id)
end
end
else
I18n.t('admin.action_logs.deleted_status')
end
+ when 'Announcement'
+ "##{attributes['id']}"
end
end
end
--- /dev/null
+# frozen_string_literal: true
+
+module Admin::AnnouncementsHelper
+ def time_range(announcement)
+ if announcement.all_day?
+ safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)])
+ else
+ safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)])
+ end
+ end
+end
InstanceFilter::KEYS,
InviteFilter::KEYS,
RelationshipFilter::KEYS,
+ AnnouncementFilter::KEYS,
].flatten.freeze
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 292.85862 204.49997" width="292.85861" height="204.49997"><g transform="translate(-395.89999 -820.4)"><defs><path id="a" d="M395.89999 745.09998H690.5v279.79999H395.89999z"/></defs><clipPath id="b"><use xlink:href="#a" width="100%" height="100%" overflow="visible"/></clipPath><path class="st53" d="M339.3 1028.6c1.5-3.2 14.4-31.3 27.4-58.8-6-9.3-2-17 1.5-23.7 1.9-3.7 3.8-7.1 3.6-10.4-.8-22 8.1-38.3 22.8-41.6 2.8-.6 5.2-.9 7.5-.9 3 0 5.6.5 8.1 1.6 1.4-1.3 2.8-2.6 4.2-3.8-2.8-2.6-4.3-5.5-4.5-8.8-.3-4.5 2.2-9.5 6.8-13.7 5.3-4.8 16.5-12.9 31.7-12.9.9 0 1.7 0 2.6.1-.4-.9-1-2-2.1-2.9-2.1-1.6-1.9-3.2-1.6-4 .7-2.1 3.6-3.2 8.1-3.2 3.9 0 9.7 1.2 14 4.4.3-.7.7-1.3 1.3-1.7.5-.3 1.3-.5 2.2-.5 3.4 0 10.6 2.7 15.5 9.9 3.6 5.3 3.6 10.8.1 16 18.3 4.7 30.1 15.6 39.5 24.4 2.5 2.4 5 4.6 7.3 6.5 10.7 8.9 21.4 13.2 32.7 13.2.9 0 1.8 0 2.7-.1 2-13.5-4.1-25.5-10-35.7-6.2-10.7-6.4-12.1-4.9-13.9l.1-.1c.6-.7 1.3-1 2.1-1.1h.3c1.7 0 4.5 1 13 8.7 9.9 9 16.9 22.2 19.2 36.5 8.9-4.9 15.2-12.5 17.1-20.3 2-8.6.5-16.8-4.2-22.7l-13.1 6.1-7-16.9-15.6 3.2 7.2-19.5h.1l-.2-.5 4.6-11.1 65.7 11.9c3.1.6 3.9 2.7 3.6 4.5l-.2 1-.4-.1c-.3.4-.6.7-1 .9-.7.3-7.5 3.6-21 9.9 2.1 2.9 2.2 6.1.4 9.4-1.1 1.9-2.5 6.2.4 13.3 3.7 8.9 3.5 29.2-8.3 46.2-8.1 11.7-18.3 23-37.6 26.6-3 4.6-6.5 9-10.2 12.6-7.8 7.6-24 15.3-42.3 15.3-5.3 0-10.7-.6-15.9-1.9-7.6 12.9-10.5 26.2-10.6 32.1-.2 11 .9 16.1.9 16.2l.4 1.8-164.9.8.9-2.3z" clip-path="url(#b)"/><path class="st53" d="M339.8 1028.8c.1-.3 13.9-30.1 27.5-59.1-6.1-9.2-2.1-16.8 1.4-23.5 2-3.7 3.8-7.3 3.7-10.6-.8-21.7 8-37.9 22.4-41 2.7-.6 5.1-.9 7.4-.9 3 0 5.7.5 8.2 1.7 1.6-1.6 3.2-3 4.9-4.4-3-2.6-4.6-5.5-4.8-8.8-.3-4.4 2.1-9.2 6.6-13.3 5.3-4.8 16.4-12.8 31.4-12.8 1.1 0 2.2 0 3.3.1-.3-1.1-1-2.7-2.5-3.8-1.8-1.4-1.7-2.8-1.5-3.4.6-1.8 3.4-2.9 7.6-2.9 4.4 0 10.3 1.5 14.3 4.8.1-.9.6-1.7 1.3-2.1.4-.3 1.1-.4 1.9-.4 3.3 0 10.3 2.6 15.1 9.7 3.6 5.3 3.5 10.9-.3 16.1 18.6 4.6 30.5 15.6 40 24.4 2.6 2.4 5 4.6 7.3 6.5 10.8 8.9 21.6 13.3 33.1 13.3 1 0 2.1 0 3.1-.1 2.2-13.9-4-26.1-10-36.4-6.3-10.9-6.2-11.8-5-13.3l.1-.1c.2-.2.7-.9 1.8-.9h.2c1.5 0 4.2 1 12.7 8.6 10 9.1 17 22.5 19.2 36.9 9.3-5 16-12.8 17.9-20.9 2.1-8.9.4-17.4-4.5-23.4l-13 6.1-6.9-16.8-15.1 3.1 6.8-18.4h.6l-.5-1 4.4-10.6 65.3 11.8c3.5.7 3.3 3.2 3.2 3.9l-.1.5h-.2c-.2.4-.6.8-1 1-.7.3-7.9 3.7-21.6 10.2.1.1.2.2.3.4 2 2.7 2.2 5.8.4 8.9-1.1 2-2.6 6.4.4 13.7 3.6 8.8 3.4 28.8-8.2 45.7-8.1 11.7-18.2 22.9-37.5 26.4-3 4.7-6.5 9.1-10.3 12.7-7.8 7.5-23.7 15.1-42 15.1-5.4 0-10.9-.7-16.1-2-7.7 13.1-10.7 26.7-10.9 32.7-.2 11.1.9 16.2.9 16.3l.3 1.2-163.5.8.5-1.7z" clip-path="url(#b)"/><path d="M577.5 843.7l-1-2.1 3.9-9.4 64.5 11.6c2.4.5 2.5 2 2.4 2.8" clip-path="url(#b)" fill="#fff" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M584.2 856.7l6.9 16.6s53.7-25.1 55.5-26c.9-.5 1.5-2.2-1-2.3-2.5-.1-69.6-1.4-69.6-1.4l-5.9 16 14.1-2.9z" clip-path="url(#b)" fill="#fff" stroke="#000" stroke-width="1.70000005" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M585.1 857l58.9-11.2 1.4-.5h.4c.8 0 .7.3.8.6.1.3-.1.6-.4.8L587 861.5l-1.9-4.5z" clip-path="url(#b)" fill="#d1d3d4"/><path class="st57" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M584.20001 856.70001l61.79999-11.5"/><path class="st58" d="M388.5 927.9c20-40 49.9-56.3 83.4-54.9 33.6 1.4 48.8 21.7 62.4 32.9 13.6 11.2 27.5 15.6 43.4 12.5 15.9-3.1 28.5-14.6 31.2-26.1 2.7-11.5-1-20.7-6.1-25.8-5.1-5.1-1.4-8.1 4.1-6.8 5.4 1.4 8.1 3.4 8.1 3.4s7.8-6.4 9.8-3.7c2 2.7 1.7 5.4.3 7.8-1.4 2.4-2.7 7.1.3 14.6 3.1 7.5 4.1 27.1-8.1 44.8-12.2 17.6-26.5 30.1-62.1 26.5-38.5-3.9-54.6 44.2-54.9 59-.2 11.5.9 16.5.9 16.5l-160.7.8c.2-.1 33.4-72.4 48-101.5z" clip-path="url(#b)" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st59" d="M399.4 989.3c-6.1 0-9.9-4.3-10-4.3l-.2-.2-.3.1c-3.1 1-6.1 1.5-9 1.5-6.1 0-11.4-2.2-15.6-6.5 1.4-3 2.9-6.1 4.3-9.1.1.1.1.2.2.3 6.1 8 12.7 9.7 17 9.7 3.2 0 5.6-.9 6.6-1.3.7 1.3 3 4.7 7.5 4.7 1.2 0 2.5-.3 3.9-.8 6.7-2.5 15.7-13.1 18.3-22.1 3.2 5.7-2.8 18.3-13.8 25.3-3 1.7-6 2.7-8.9 2.7zm24.5-78.1c-5.7-11.1-10.8-14.2-12.4-14.9 15.7-15.3 34.7-23 56.6-23 1.3 0 2.5 0 3.8.1 2.1.1 4.2.3 6.4.5 1.5 2.7-.4 5.6-.8 6.3-3 .8-11.2 6.5-18.5 11.6-1.9 1.3-3.7 2.6-5.3 3.7-5.3 3.7-13.6 4.2-18 4.2-1.8 0-2.9-.1-2.9-.1h-.3l-8.6 11.6zm95.3 49c4.2-7.1 12.6-15.2 28.3-15.2 3.5 0 7.2.4 11 1.2 3.4.7 7 1.1 10.6 1.1 15.9 0 29.8-7.9 34.8-12.5 6.5-6 10.5-9.8 12-12.4 1.4-2.4 3.2-2.8 4.7-2 .1.1-.7 1.1-.6 1.1-.9 1.7-1.9 3.3-3 4.8-11.5 16.6-23.9 26.9-50 26.9-3.6 0-7.5-.2-11.6-.6-1.5-.2-3-.2-4.4-.2-9.4 0-18.1 3.2-25.9 9.4l-5.9-1.6z" clip-path="url(#b)" fill="#38434f"/><path class="st58" d="M427.4 920.5c-9.1-24.2-19-27.9-32.4-25-13.4 3-22.4 18-21.6 40 .4 10.2-15.5 20.5-4.3 35s23.5 8 23.5 8 3 7.1 11 4.1 16.7-13.5 18.5-21.6" clip-path="url(#b)" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st59" d="M417.2 934.2c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.4 11.7-14.2 19c.6 15.3-10.2 22.8 0 29.7 10.1 6.9 23.7-2.8 28.5-11.3 4.8-8.5 4-11.8 4-11.8z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M392.6 978.6c6.3-3.9 9.1-7.5 9.1-7.5m15.5-36.9c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.6 11.7-14.2 19" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M417.5 891.4c-9.8-6.9-6.2-16 .3-21.8 6.5-5.8 19-14 35.2-12.2 0 0-.2-3.5-3.1-5.8s-.5-4.5 5.5-4.5 12.9 2.6 15.8 6.9c0 0-1.4-3.2.4-4.4 1.8-1.2 10.2 1 15.6 9s1.1 14.9-5.2 19.9c-6.3 5-36.2 11.6-48.8 10.9" clip-path="url(#b)" fill="#53606c"/><g class="st53" clip-path="url(#b)"><path class="st36" d="M459.5 878.7c-11.1.6-22.1 3.3-32.9 6.1-4.1 1.1-8.2 2.2-12.4 2-.4 0-.8-.1-1.3-.1 1 1.6 2.5 3.2 4.6 4.7l15.6-2.1c11.3.7 37-4.7 46.4-9.4-6.5-1.1-13.3-1.5-20-1.2z" fill="#38434f"/></g><path class="st59" d="M417.5 890.8c-3.5-2.5-5.3-5.5-5.5-8.8-.2-3.2 1.2-6.7 4-9.9-.4 1.6-.2 3.2.5 4.7 1.2 2.5 3.6 4 6.3 4 2.1 0 4.3-.9 6.3-2.6 7.2-6 19.4-10.4 29.1-10.4 1.9 0 3.7.2 5.3.5 7.7 1.6 12.3 3.7 13.8 6.7.8 1.6.8 3.3 0 5.4-10.1 4.3-31.2 8.6-42.1 8.6-.7 0-1.4 0-2.1-.1-1.4-.1-2.8-.1-4-.1-7.5-.1-10.6 1.4-11.6 2z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M471.2 854c2.2 3.4 1.1 6.7 1.1 6.7" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M489.6 967.9c34.6 23.5 68.7 8.7 80.2-2.4 11.4-11.1 20.8-28.5 20-45.9-.8-17.4-8.5-32.9-19.5-42.8-10.7-9.6-12.4-8.7-13.2-7.7-.8 1-1.4 1.1 5 12.2s13.4 24.6 9.5 40.2c-3.9 15.6-15.6 29.9-29.6 34-14 4-21.6-1.4-26.1-3.2 0 0 1 4-3.4 4.3-4.3.3-11.1-2.9-11.1-2.9s2.7 3 .1 5-5.9-.9-5.9-.9 2.5 2.2 1.5 3.3c-1 1.1-2.3.8-4.2-.6 0 0 3.2 3.6 1 5.5s-4.3 1.9-4.3 1.9z" clip-path="url(#b)" fill="#b3becd" stroke="#000" stroke-width="1.60000002" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M528.6 979.9c-13.1 0-25.8-3.9-37.6-11.7.9-.2 2.1-.7 3.3-1.8 1.4-1.3 1-3 .3-4.3.4.1.8.2 1.1.2.7 0 1.3-.3 1.8-.8.3-.3.4-.6.4-1 0-.3-.1-.6-.2-.9.6.2 1.2.4 1.9.4.9 0 1.7-.3 2.4-.8.3-.2.5-.5.7-.8 12.6 6.2 22.6 9.1 31.5 9.1 7.8 0 14.7-2.3 21.1-6.9 16.8-12.3 21.3-21.3 24.6-27.9l.3-.6c2.3-4.5 4.2-6.5 6.4-6.5.9 0 1.7.3 2.7.9-1.4 13.6-8.7 28-19.6 38.6-7.9 7.4-23.4 14.8-41.1 14.8z" clip-path="url(#b)" fill="#92a1b5"/><path class="st57" d="M489.6 967.9c-3.9.1-6.7-.9-6.7-.9" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M484 908.3c-2.2.9-2.1 3.2.1 6.7 2.1 3.5 4.5 7.9 5.9 10.3 1.4 2.4 2.6 3.5 4 2.8 1.4-.6 1.8-2 .4-4.9-1.4-2.9-5.8-11-7.2-12.7-1.6-1.7-2.4-2.5-3.2-2.2z" clip-path="url(#b)" fill="#38434f" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st57" d="M525.9 907.8c2.5-3.7 4.8-5.2 4.8-5.2m3.8 11.9c1.5-3.1 3.7-5.6 3.7-5.6m11.9 13.3c1-3.9 2.7-5.6 2.7-5.6m10.7 9c.6-4.6.9-6.6.9-6.6" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><g class="st53" clip-path="url(#b)"><path class="st49" d="M504.9 862.2c.8-.4 1.5-.8 2.2-1.1" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M519.8 856.2c13.6-3.5 22.1.5 28.2 3.3 5.3 2.4 11.6 4.7 17.6 5.3" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.5286,13.5857"/><path class="st49" d="M572.4 864.5c.8-.2 1.6-.4 2.4-.7" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g><g class="st53" clip-path="url(#b)"><path d="M491.2 946.7c-4.1 1.1-6.1 1.8-10 3.5-2.1.9-4.6.1-5.6-1.9s0-4.6 2.3-5.6c4.3-1.9 6.6-2.7 11.2-3.9 2.4-.6 4.8.8 5.3 2.9.4 2.2-1 4.4-3.2 5z" fill="#505762"/></g><path class="st57" d="M515.8 952.3c-.2-2.3-1.7-3.3-1.7-3.3" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><g class="st53" clip-path="url(#b)"><path d="M454.5 887.5c-3.5.7-7.1 1.3-10.6 1.7-3.6.4-7.2.8-10.8.6-.3 0-.5-.2-.5-.5s.2-.5.5-.5c3.5.2 7.1-.1 10.6-.4 3.5-.4 7.1-.9 10.6-1.6.2 0 .4.1.4.3.1.2 0 .4-.2.4z"/></g><g class="st53" clip-path="url(#b)"><path d="M417.2 891.8c-2.2-1.6-4.3-3.6-5.4-6.2l-.4-1-.2-1-.1-.5v-.5c0-.4-.1-.7-.1-1.1 0-1.4.2-2.8.7-4.1.9-2.6 2.6-4.9 4.4-6.9 1.9-2 4-3.6 6.2-5.2s4.6-2.9 7-4.1c2.4-1.2 5-2.2 7.6-2.9 5.2-1.5 10.7-1.9 16.1-1.4l-.6.5c-.2-1.3-.7-2.7-1.4-3.8-.4-.6-.8-1.1-1.4-1.5-.5-.5-1.2-1-1.5-1.9-.1-.2-.1-.5-.1-.7.1-.2.1-.5.2-.7.2-.4.6-.7.9-.9.7-.5 1.4-.7 2.2-.9 1.5-.3 3-.4 4.4-.4 2.9.1 5.9.7 8.6 1.8 2.7 1.1 5.3 2.8 7.1 5.3l-.9.5c-.3-.7-.5-1.3-.5-2.1-.1-.7 0-1.5.4-2.3.3-.3.5-.7 1-.8.2-.1.4-.1.6-.2.2 0 .4-.1.6-.1.7 0 1.4.1 2.1.2 1.4.3 2.6.8 3.9 1.4 1.2.6 2.4 1.3 3.5 2.1 2.2 1.6 4.1 3.6 5.6 5.9.7 1.2 1.4 2.4 1.8 3.7.3.6.4 1.3.5 2 .1.3.1.7.1 1v1c-.1 2.8-1.3 5.5-3 7.6-1.7 2.2-3.6 4-5.9 5.6-2.4 1.4-4.9 2.3-7.4 3.2-2.5.8-5.1 1.5-7.8 1.9 1.2-.5 2.5-.9 3.7-1.4l3.7-1.3c2.5-.9 5-1.8 7.2-3.2 2.1-1.5 4-3.3 5.6-5.4 1.5-2.1 2.6-4.5 2.7-7 .1-2.5-.8-5-2.2-7.1-1.4-2.2-3.2-4.1-5.3-5.6-2.1-1.5-4.4-2.8-6.9-3.4-.6-.1-1.2-.2-1.8-.2-.6 0-1 .1-1.3.5-.5.8-.2 2.3.2 3.4.1.3 0 .5-.3.6-.2.1-.4 0-.6-.2-1.6-2.3-4-3.8-6.6-4.9-2.6-1.1-5.4-1.7-8.2-1.7-1.4 0-2.8.1-4.2.4-.7.2-1.3.4-1.8.7-.5.4-.8.8-.7 1.3.1.5.6.9 1.2 1.4.6.5 1.1 1.1 1.5 1.7.9 1.3 1.4 2.7 1.6 4.3 0 .3-.2.5-.4.5h-.1c-5.3-.5-10.7-.1-15.8 1.4-2.5.7-5 1.7-7.4 2.8-2.4 1.2-4.7 2.5-6.8 4.1-2.2 1.5-4.3 3.2-6.1 5.1-1.8 1.9-3.4 4.1-4.2 6.5-.4 1.2-.6 2.5-.7 3.8 0 .3.1.6.1 1v.5l.1.5.2.9.4.9c1 2.4 2.9 4.3 5 5.8.2.2.3.5.1.7-.2.2-.4.3-.7.1z"/></g><g class="st53" clip-path="url(#b)"><path class="st36" d="M615.1 863.2c2 1.2 2.3 1.8 2.3 1.8" fill="#38434f"/><path d="M615.3 862.9l1.2.9c.2.2.4.3.6.5.2.2.3.4.5.6 0 .1 0 .2-.1.2h-.2l-.5-.5c-.2-.1-.4-.3-.6-.4-.4-.3-.8-.5-1.3-.7-.2-.1-.2-.3-.1-.5s.2-.2.5-.1c-.1 0-.1 0 0 0z"/></g></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 252.85863 194.49997" width="252.85863" height="194.49997"><g transform="translate(-395.89999 -830.4)"><defs><path id="a" d="M395.89999 745.09998H690.5V1024.9H395.89999z"/></defs><clipPath id="b"><use xlink:href="#a" width="100%" height="100%" overflow="visible"/></clipPath><path class="st53" d="M339.3 1028.6c1.5-3.2 14.4-31.3 27.4-58.8-6-9.3-2-17 1.5-23.7 1.9-3.7 3.8-7.1 3.6-10.4-.8-22 8.1-38.3 22.8-41.6 2.8-.6 5.2-.9 7.5-.9 3 0 5.6.5 8.1 1.6 1.4-1.3 2.8-2.6 4.2-3.8-2.8-2.6-4.3-5.5-4.5-8.8-.3-4.5 2.2-9.5 6.8-13.7 5.3-4.8 16.5-12.9 31.7-12.9.9 0 1.7 0 2.6.1-.4-.9-1-2-2.1-2.9-2.1-1.6-1.9-3.2-1.6-4 .7-2.1 3.6-3.2 8.1-3.2 3.9 0 9.7 1.2 14 4.4.3-.7.7-1.3 1.3-1.7.5-.3 1.3-.5 2.2-.5 3.4 0 10.6 2.7 15.5 9.9 3.6 5.3 3.6 10.8.1 16 18.3 4.7 30.1 15.6 39.5 24.4 2.5 2.4 5 4.6 7.3 6.5 10.7 8.9 21.4 13.2 32.7 13.2.9 0 1.8 0 2.7-.1 2-13.5-4.1-25.5-10-35.7-6.2-10.7-6.4-12.1-4.9-13.9l.1-.1c.6-.7 1.3-1 2.1-1.1h.3c1.7 0 4.5 1 13 8.7 9.9 9 16.9 22.2 19.2 36.5 8.9-4.9 15.2-12.5 17.1-20.3 2-8.6.5-16.8-4.2-22.7l-13.1 6.1-7-16.9-15.6 3.2 7.2-19.5h.1l-.2-.5 4.6-11.1 65.7 11.9c3.1.6 3.9 2.7 3.6 4.5l-.2 1-.4-.1c-.3.4-.6.7-1 .9-.7.3-7.5 3.6-21 9.9 2.1 2.9 2.2 6.1.4 9.4-1.1 1.9-2.5 6.2.4 13.3 3.7 8.9 3.5 29.2-8.3 46.2-8.1 11.7-18.3 23-37.6 26.6-3 4.6-6.5 9-10.2 12.6-7.8 7.6-24 15.3-42.3 15.3-5.3 0-10.7-.6-15.9-1.9-7.6 12.9-10.5 26.2-10.6 32.1-.2 11 .9 16.1.9 16.2l.4 1.8-164.9.8.9-2.3z" clip-path="url(#b)"/><path class="st53" d="M339.8 1028.8c.1-.3 13.9-30.1 27.5-59.1-6.1-9.2-2.1-16.8 1.4-23.5 2-3.7 3.8-7.3 3.7-10.6-.8-21.7 8-37.9 22.4-41 2.7-.6 5.1-.9 7.4-.9 3 0 5.7.5 8.2 1.7 1.6-1.6 3.2-3 4.9-4.4-3-2.6-4.6-5.5-4.8-8.8-.3-4.4 2.1-9.2 6.6-13.3 5.3-4.8 16.4-12.8 31.4-12.8 1.1 0 2.2 0 3.3.1-.3-1.1-1-2.7-2.5-3.8-1.8-1.4-1.7-2.8-1.5-3.4.6-1.8 3.4-2.9 7.6-2.9 4.4 0 10.3 1.5 14.3 4.8.1-.9.6-1.7 1.3-2.1.4-.3 1.1-.4 1.9-.4 3.3 0 10.3 2.6 15.1 9.7 3.6 5.3 3.5 10.9-.3 16.1 18.6 4.6 30.5 15.6 40 24.4 2.6 2.4 5 4.6 7.3 6.5 10.8 8.9 21.6 13.3 33.1 13.3 1 0 2.1 0 3.1-.1 2.2-13.9-4-26.1-10-36.4-6.3-10.9-6.2-11.8-5-13.3l.1-.1c.2-.2.7-.9 1.8-.9h.2c1.5 0 4.2 1 12.7 8.6 10 9.1 17 22.5 19.2 36.9 9.3-5 16-12.8 17.9-20.9 2.1-8.9.4-17.4-4.5-23.4l-13 6.1-6.9-16.8-15.1 3.1 6.8-18.4h.6l-.5-1 4.4-10.6 65.3 11.8c3.5.7 3.3 3.2 3.2 3.9l-.1.5h-.2c-.2.4-.6.8-1 1-.7.3-7.9 3.7-21.6 10.2.1.1.2.2.3.4 2 2.7 2.2 5.8.4 8.9-1.1 2-2.6 6.4.4 13.7 3.6 8.8 3.4 28.8-8.2 45.7-8.1 11.7-18.2 22.9-37.5 26.4-3 4.7-6.5 9.1-10.3 12.7-7.8 7.5-23.7 15.1-42 15.1-5.4 0-10.9-.7-16.1-2-7.7 13.1-10.7 26.7-10.9 32.7-.2 11.1.9 16.2.9 16.3l.3 1.2-163.5.8.5-1.7z" clip-path="url(#b)"/><path d="M577.5 843.7l-1-2.1 3.9-9.4 64.5 11.6c2.4.5 2.5 2 2.4 2.8" clip-path="url(#b)" stroke-miterlimit="10" fill="#fff" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M584.2 856.7l6.9 16.6s53.7-25.1 55.5-26c.9-.5 1.5-2.2-1-2.3-2.5-.1-69.6-1.4-69.6-1.4l-5.9 16 14.1-2.9z" clip-path="url(#b)" stroke-miterlimit="10" fill="#fff" stroke="#000" stroke-width="1.70000005" stroke-linecap="round" stroke-linejoin="round"/><path d="M585.1 857l58.9-11.2 1.4-.5h.4c.8 0 .7.3.8.6.1.3-.1.6-.4.8L587 861.5l-1.9-4.5z" clip-path="url(#b)" fill="#d1d3d4"/><path class="st57" clip-path="url(#b)" stroke-miterlimit="10" d="M584.20001 856.70001l61.79999-11.5" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st58" d="M388.5 927.9c20-40 49.9-56.3 83.4-54.9 33.6 1.4 48.8 21.7 62.4 32.9 13.6 11.2 27.5 15.6 43.4 12.5 15.9-3.1 28.5-14.6 31.2-26.1 2.7-11.5-1-20.7-6.1-25.8-5.1-5.1-1.4-8.1 4.1-6.8 5.4 1.4 8.1 3.4 8.1 3.4s7.8-6.4 9.8-3.7c2 2.7 1.7 5.4.3 7.8-1.4 2.4-2.7 7.1.3 14.6 3.1 7.5 4.1 27.1-8.1 44.8-12.2 17.6-26.5 30.1-62.1 26.5-38.5-3.9-54.6 44.2-54.9 59-.2 11.5.9 16.5.9 16.5l-160.7.8c.2-.1 33.4-72.4 48-101.5z" clip-path="url(#b)" stroke-miterlimit="10" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st59" d="M399.4 989.3c-6.1 0-9.9-4.3-10-4.3l-.2-.2-.3.1c-3.1 1-6.1 1.5-9 1.5-6.1 0-11.4-2.2-15.6-6.5 1.4-3 2.9-6.1 4.3-9.1.1.1.1.2.2.3 6.1 8 12.7 9.7 17 9.7 3.2 0 5.6-.9 6.6-1.3.7 1.3 3 4.7 7.5 4.7 1.2 0 2.5-.3 3.9-.8 6.7-2.5 15.7-13.1 18.3-22.1 3.2 5.7-2.8 18.3-13.8 25.3-3 1.7-6 2.7-8.9 2.7zm24.5-78.1c-5.7-11.1-10.8-14.2-12.4-14.9 15.7-15.3 34.7-23 56.6-23 1.3 0 2.5 0 3.8.1 2.1.1 4.2.3 6.4.5 1.5 2.7-.4 5.6-.8 6.3-3 .8-11.2 6.5-18.5 11.6-1.9 1.3-3.7 2.6-5.3 3.7-5.3 3.7-13.6 4.2-18 4.2-1.8 0-2.9-.1-2.9-.1h-.3l-8.6 11.6zm95.3 49c4.2-7.1 12.6-15.2 28.3-15.2 3.5 0 7.2.4 11 1.2 3.4.7 7 1.1 10.6 1.1 15.9 0 29.8-7.9 34.8-12.5 6.5-6 10.5-9.8 12-12.4 1.4-2.4 3.2-2.8 4.7-2 .1.1-.7 1.1-.6 1.1-.9 1.7-1.9 3.3-3 4.8-11.5 16.6-23.9 26.9-50 26.9-3.6 0-7.5-.2-11.6-.6-1.5-.2-3-.2-4.4-.2-9.4 0-18.1 3.2-25.9 9.4l-5.9-1.6z" clip-path="url(#b)" fill="#38434f"/><path class="st58" d="M427.4 920.5c-9.1-24.2-19-27.9-32.4-25-13.4 3-22.4 18-21.6 40 .4 10.2-15.5 20.5-4.3 35s23.5 8 23.5 8 3 7.1 11 4.1 16.7-13.5 18.5-21.6" clip-path="url(#b)" stroke-miterlimit="10" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st59" d="M417.2 934.2c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.4 11.7-14.2 19c.6 15.3-10.2 22.8 0 29.7 10.1 6.9 23.7-2.8 28.5-11.3 4.8-8.5 4-11.8 4-11.8z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M392.6 978.6c6.3-3.9 9.1-7.5 9.1-7.5m15.5-36.9c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.6 11.7-14.2 19" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M417.5 891.4c-9.8-6.9-6.2-16 .3-21.8 6.5-5.8 19-14 35.2-12.2 0 0-.2-3.5-3.1-5.8s-.5-4.5 5.5-4.5 12.9 2.6 15.8 6.9c0 0-1.4-3.2.4-4.4 1.8-1.2 10.2 1 15.6 9s1.1 14.9-5.2 19.9c-6.3 5-36.2 11.6-48.8 10.9" clip-path="url(#b)" fill="#53606c"/><g class="st53" clip-path="url(#b)"><path class="st36" d="M459.5 878.7c-11.1.6-22.1 3.3-32.9 6.1-4.1 1.1-8.2 2.2-12.4 2-.4 0-.8-.1-1.3-.1 1 1.6 2.5 3.2 4.6 4.7l15.6-2.1c11.3.7 37-4.7 46.4-9.4-6.5-1.1-13.3-1.5-20-1.2z" fill="#38434f"/></g><path class="st59" d="M417.5 890.8c-3.5-2.5-5.3-5.5-5.5-8.8-.2-3.2 1.2-6.7 4-9.9-.4 1.6-.2 3.2.5 4.7 1.2 2.5 3.6 4 6.3 4 2.1 0 4.3-.9 6.3-2.6 7.2-6 19.4-10.4 29.1-10.4 1.9 0 3.7.2 5.3.5 7.7 1.6 12.3 3.7 13.8 6.7.8 1.6.8 3.3 0 5.4-10.1 4.3-31.2 8.6-42.1 8.6-.7 0-1.4 0-2.1-.1-1.4-.1-2.8-.1-4-.1-7.5-.1-10.6 1.4-11.6 2z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M471.2 854c2.2 3.4 1.1 6.7 1.1 6.7" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M489.6 967.9c34.6 23.5 68.7 8.7 80.2-2.4 11.4-11.1 20.8-28.5 20-45.9-.8-17.4-8.5-32.9-19.5-42.8-10.7-9.6-12.4-8.7-13.2-7.7-.8 1-1.4 1.1 5 12.2s13.4 24.6 9.5 40.2c-3.9 15.6-15.6 29.9-29.6 34-14 4-21.6-1.4-26.1-3.2 0 0 1 4-3.4 4.3-4.3.3-11.1-2.9-11.1-2.9s2.7 3 .1 5-5.9-.9-5.9-.9 2.5 2.2 1.5 3.3c-1 1.1-2.3.8-4.2-.6 0 0 3.2 3.6 1 5.5s-4.3 1.9-4.3 1.9z" clip-path="url(#b)" stroke-miterlimit="10" fill="#b3becd" stroke="#000" stroke-width="1.60000002" stroke-linecap="round" stroke-linejoin="round"/><path d="M528.6 979.9c-13.1 0-25.8-3.9-37.6-11.7.9-.2 2.1-.7 3.3-1.8 1.4-1.3 1-3 .3-4.3.4.1.8.2 1.1.2.7 0 1.3-.3 1.8-.8.3-.3.4-.6.4-1 0-.3-.1-.6-.2-.9.6.2 1.2.4 1.9.4.9 0 1.7-.3 2.4-.8.3-.2.5-.5.7-.8 12.6 6.2 22.6 9.1 31.5 9.1 7.8 0 14.7-2.3 21.1-6.9 16.8-12.3 21.3-21.3 24.6-27.9l.3-.6c2.3-4.5 4.2-6.5 6.4-6.5.9 0 1.7.3 2.7.9-1.4 13.6-8.7 28-19.6 38.6-7.9 7.4-23.4 14.8-41.1 14.8z" clip-path="url(#b)" fill="#92a1b5"/><path class="st57" d="M489.6 967.9c-3.9.1-6.7-.9-6.7-.9" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M484 908.3c-2.2.9-2.1 3.2.1 6.7 2.1 3.5 4.5 7.9 5.9 10.3 1.4 2.4 2.6 3.5 4 2.8 1.4-.6 1.8-2 .4-4.9-1.4-2.9-5.8-11-7.2-12.7-1.6-1.7-2.4-2.5-3.2-2.2z" clip-path="url(#b)" stroke-miterlimit="10" fill="#38434f" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path class="st57" d="M525.9 907.8c2.5-3.7 4.8-5.2 4.8-5.2m3.8 11.9c1.5-3.1 3.7-5.6 3.7-5.6m11.9 13.3c1-3.9 2.7-5.6 2.7-5.6m10.7 9c.6-4.6.9-6.6.9-6.6" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><g class="st53" clip-path="url(#b)"><path class="st49" d="M504.9 862.2c.8-.4 1.5-.8 2.2-1.1" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M519.8 856.2c13.6-3.5 22.1.5 28.2 3.3 5.3 2.4 11.6 4.7 17.6 5.3" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.5286,13.5857"/><path class="st49" d="M572.4 864.5c.8-.2 1.6-.4 2.4-.7" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g><g class="st53" clip-path="url(#b)"><path d="M491.2 946.7c-4.1 1.1-6.1 1.8-10 3.5-2.1.9-4.6.1-5.6-1.9s0-4.6 2.3-5.6c4.3-1.9 6.6-2.7 11.2-3.9 2.4-.6 4.8.8 5.3 2.9.4 2.2-1 4.4-3.2 5z" fill="#505762"/></g><path class="st57" d="M515.8 952.3c-.2-2.3-1.7-3.3-1.7-3.3" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><g class="st53" clip-path="url(#b)"><path d="M454.5 887.5c-3.5.7-7.1 1.3-10.6 1.7-3.6.4-7.2.8-10.8.6-.3 0-.5-.2-.5-.5s.2-.5.5-.5c3.5.2 7.1-.1 10.6-.4 3.5-.4 7.1-.9 10.6-1.6.2 0 .4.1.4.3.1.2 0 .4-.2.4z"/></g><g class="st53" clip-path="url(#b)"><path d="M417.2 891.8c-2.2-1.6-4.3-3.6-5.4-6.2l-.4-1-.2-1-.1-.5v-.5c0-.4-.1-.7-.1-1.1 0-1.4.2-2.8.7-4.1.9-2.6 2.6-4.9 4.4-6.9 1.9-2 4-3.6 6.2-5.2s4.6-2.9 7-4.1c2.4-1.2 5-2.2 7.6-2.9 5.2-1.5 10.7-1.9 16.1-1.4l-.6.5c-.2-1.3-.7-2.7-1.4-3.8-.4-.6-.8-1.1-1.4-1.5-.5-.5-1.2-1-1.5-1.9-.1-.2-.1-.5-.1-.7.1-.2.1-.5.2-.7.2-.4.6-.7.9-.9.7-.5 1.4-.7 2.2-.9 1.5-.3 3-.4 4.4-.4 2.9.1 5.9.7 8.6 1.8 2.7 1.1 5.3 2.8 7.1 5.3l-.9.5c-.3-.7-.5-1.3-.5-2.1-.1-.7 0-1.5.4-2.3.3-.3.5-.7 1-.8.2-.1.4-.1.6-.2.2 0 .4-.1.6-.1.7 0 1.4.1 2.1.2 1.4.3 2.6.8 3.9 1.4 1.2.6 2.4 1.3 3.5 2.1 2.2 1.6 4.1 3.6 5.6 5.9.7 1.2 1.4 2.4 1.8 3.7.3.6.4 1.3.5 2 .1.3.1.7.1 1v1c-.1 2.8-1.3 5.5-3 7.6-1.7 2.2-3.6 4-5.9 5.6-2.4 1.4-4.9 2.3-7.4 3.2-2.5.8-5.1 1.5-7.8 1.9 1.2-.5 2.5-.9 3.7-1.4l3.7-1.3c2.5-.9 5-1.8 7.2-3.2 2.1-1.5 4-3.3 5.6-5.4 1.5-2.1 2.6-4.5 2.7-7 .1-2.5-.8-5-2.2-7.1-1.4-2.2-3.2-4.1-5.3-5.6-2.1-1.5-4.4-2.8-6.9-3.4-.6-.1-1.2-.2-1.8-.2-.6 0-1 .1-1.3.5-.5.8-.2 2.3.2 3.4.1.3 0 .5-.3.6-.2.1-.4 0-.6-.2-1.6-2.3-4-3.8-6.6-4.9-2.6-1.1-5.4-1.7-8.2-1.7-1.4 0-2.8.1-4.2.4-.7.2-1.3.4-1.8.7-.5.4-.8.8-.7 1.3.1.5.6.9 1.2 1.4.6.5 1.1 1.1 1.5 1.7.9 1.3 1.4 2.7 1.6 4.3 0 .3-.2.5-.4.5h-.1c-5.3-.5-10.7-.1-15.8 1.4-2.5.7-5 1.7-7.4 2.8-2.4 1.2-4.7 2.5-6.8 4.1-2.2 1.5-4.3 3.2-6.1 5.1-1.8 1.9-3.4 4.1-4.2 6.5-.4 1.2-.6 2.5-.7 3.8 0 .3.1.6.1 1v.5l.1.5.2.9.4.9c1 2.4 2.9 4.3 5 5.8.2.2.3.5.1.7-.2.2-.4.3-.7.1z"/></g><g class="st53" clip-path="url(#b)"><path class="st36" d="M615.1 863.2c2 1.2 2.3 1.8 2.3 1.8" fill="#38434f"/><path d="M615.3 862.9l1.2.9c.2.2.4.3.6.5.2.2.3.4.5.6 0 .1 0 .2-.1.2h-.2l-.5-.5c-.2-.1-.4-.3-.6-.4-.4-.3-.8-.5-1.3-.7-.2-.1-.2-.3-.1-.5s.2-.2.5-.1c-.1 0-.1 0 0 0z"/></g></g></svg>
--- /dev/null
+import api from '../api';
+import { normalizeAnnouncement } from './importer/normalizer';
+
+export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
+export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
+export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
+export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
+export const ANNOUNCEMENTS_DISMISS = 'ANNOUNCEMENTS_DISMISS';
+
+export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
+export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
+export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
+
+const noOp = () => {};
+
+export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
+ dispatch(fetchAnnouncementsRequest());
+
+ api(getState).get('/api/v1/announcements').then(response => {
+ dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
+ }).catch(error => {
+ dispatch(fetchAnnouncementsFail(error));
+ }).finally(() => {
+ done();
+ });
+};
+
+export const fetchAnnouncementsRequest = () => ({
+ type: ANNOUNCEMENTS_FETCH_REQUEST,
+ skipLoading: true,
+});
+
+export const fetchAnnouncementsSuccess = announcements => ({
+ type: ANNOUNCEMENTS_FETCH_SUCCESS,
+ announcements,
+ skipLoading: true,
+});
+
+export const fetchAnnouncementsFail= error => ({
+ type: ANNOUNCEMENTS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+});
+
+export const updateAnnouncements = announcement => ({
+ type: ANNOUNCEMENTS_UPDATE,
+ announcement: normalizeAnnouncement(announcement),
+});
+
+export const dismissAnnouncement = announcementId => (dispatch, getState) => {
+ dispatch({
+ type: ANNOUNCEMENTS_DISMISS,
+ id: announcementId,
+ });
+
+ api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`);
+};
+
+export const addReaction = (announcementId, name) => (dispatch, getState) => {
+ dispatch(addReactionRequest(announcementId, name));
+
+ api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+ dispatch(addReactionSuccess(announcementId, name));
+ }).catch(err => {
+ dispatch(addReactionFail(announcementId, name, err));
+ });
+};
+
+export const addReactionRequest = (announcementId, name) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const addReactionSuccess = (announcementId, name) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const addReactionFail = (announcementId, name, error) => ({
+ type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ id: announcementId,
+ name,
+ error,
+ skipLoading: true,
+});
+
+export const removeReaction = (announcementId, name) => (dispatch, getState) => {
+ dispatch(removeReactionRequest(announcementId, name));
+
+ api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+ dispatch(removeReactionSuccess(announcementId, name));
+ }).catch(err => {
+ dispatch(removeReactionFail(announcementId, name, err));
+ });
+};
+
+export const removeReactionRequest = (announcementId, name) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const removeReactionSuccess = (announcementId, name) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
+ id: announcementId,
+ name,
+ skipLoading: true,
+});
+
+export const removeReactionFail = (announcementId, name, error) => ({
+ type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+ id: announcementId,
+ name,
+ error,
+ skipLoading: true,
+});
+
+export const updateReaction = reaction => ({
+ type: ANNOUNCEMENTS_REACTION_UPDATE,
+ reaction,
+});
export function normalizePoll(poll) {
const normalPoll = { ...poll };
-
const emojiMap = makeEmojiMap(normalPoll);
normalPoll.options = poll.options.map((option, index) => ({
return normalPoll;
}
+
+export function normalizeAnnouncement(announcement) {
+ const normalAnnouncement = { ...announcement };
+ const emojiMap = makeEmojiMap(normalAnnouncement);
+
+ normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+ return normalAnnouncement;
+}
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
- done();
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
+ }).finally(() => {
done();
});
};
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
+ skipAlert: !isLoadingMore,
};
};
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
+import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
case 'filters_changed':
dispatch(fetchFilters());
break;
+ case 'announcement':
+ dispatch(updateAnnouncements(JSON.parse(data.payload)));
+ break;
+ case 'announcement.reaction':
+ dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+ break;
}
},
};
}
const refreshHomeTimelineAndNotification = (dispatch, done) => {
- dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+ dispatch(expandHomeTimeline({}, () =>
+ dispatch(expandNotifications({}, () =>
+ dispatch(fetchAnnouncements(done))))));
};
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
- done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+ }).finally(() => {
done();
});
};
<div>
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
- <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
+ <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div>
</div>
);
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
+ button: PropTypes.node,
};
state = {
}
render () {
- const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
- <img
+ {button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
- />
+ />}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
--- /dev/null
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import Icon from 'mastodon/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
+import { autoPlayGif } from 'mastodon/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'mastodon/initial_state';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
+
+const messages = defineMessages({
+ close: { id: 'lightbox.close', defaultMessage: 'Close' },
+ previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+ next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ announcement: ImmutablePropTypes.map.isRequired,
+ };
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidMount () {
+ this._updateLinks();
+ this._updateEmojis();
+ }
+
+ componentDidUpdate () {
+ this._updateLinks();
+ this._updateEmojis();
+ }
+
+ _updateEmojis () {
+ const node = this.node;
+
+ if (!node || autoPlayGif) {
+ return;
+ }
+
+ const emojis = node.querySelectorAll('.custom-emoji');
+
+ for (var i = 0; i < emojis.length; i++) {
+ let emoji = emojis[i];
+
+ if (emoji.classList.contains('status-emoji')) {
+ continue;
+ }
+
+ emoji.classList.add('status-emoji');
+
+ emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+ emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+ }
+ }
+
+ _updateLinks () {
+ const node = this.node;
+
+ if (!node) {
+ return;
+ }
+
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+
+ link.classList.add('status-link');
+
+ let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '');
+
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleEmojiMouseEnter = ({ target }) => {
+ target.src = target.getAttribute('data-original');
+ }
+
+ handleEmojiMouseLeave = ({ target }) => {
+ target.src = target.getAttribute('data-static');
+ }
+
+ render () {
+ const { announcement } = this.props;
+
+ return (
+ <div
+ className='announcements__item__content'
+ ref={this.setRef}
+ dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+ />
+ );
+ }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.string.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ hovered: PropTypes.bool.isRequired,
+ };
+
+ render () {
+ const { emoji, emojiMap, hovered } = this.props;
+
+ if (unicodeMapping[emoji]) {
+ const { filename, shortCode } = unicodeMapping[this.props.emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+ <img
+ draggable='false'
+ className='emojione'
+ alt={emoji}
+ title={title}
+ src={`${assetHost}/emoji/${filename}.svg`}
+ />
+ );
+ } else if (emojiMap.get(emoji)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+ <img
+ draggable='false'
+ className='emojione custom-emoji'
+ alt={shortCode}
+ title={shortCode}
+ src={filename}
+ />
+ );
+ } else {
+ return null;
+ }
+ }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reaction: ImmutablePropTypes.map.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ hovered: false,
+ };
+
+ handleClick = () => {
+ const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+ if (reaction.get('me')) {
+ removeReaction(announcementId, reaction.get('name'));
+ } else {
+ addReaction(announcementId, reaction.get('name'));
+ }
+ }
+
+ handleMouseEnter = () => this.setState({ hovered: true })
+
+ handleMouseLeave = () => this.setState({ hovered: false })
+
+ render () {
+ const { reaction } = this.props;
+
+ let shortCode = reaction.get('name');
+
+ if (unicodeMapping[shortCode]) {
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+ <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
+ <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+ <span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span>
+ </button>
+ );
+ }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reactions: ImmutablePropTypes.list.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleEmojiPick = data => {
+ const { addReaction, announcementId } = this.props;
+ addReaction(announcementId, data.native.replace(/:/g, ''));
+ }
+
+ render () {
+ const { reactions } = this.props;
+ const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+ return (
+ <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+ {visibleReactions.map(reaction => (
+ <Reaction
+ key={reaction.get('name')}
+ reaction={reaction}
+ announcementId={this.props.announcementId}
+ addReaction={this.props.addReaction}
+ removeReaction={this.props.removeReaction}
+ emojiMap={this.props.emojiMap}
+ />
+ ))}
+
+ <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />
+ </div>
+ );
+ }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcement: ImmutablePropTypes.map.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ dismissAnnouncement: PropTypes.func.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleDismissClick = () => {
+ const { dismissAnnouncement, announcement } = this.props;
+ dismissAnnouncement(announcement.get('id'));
+ }
+
+ render () {
+ const { announcement, intl } = this.props;
+ const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+ const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+ const now = new Date();
+ const hasTimeRange = startsAt && endsAt;
+ const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+ const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+ const skipTime = announcement.get('all_day');
+
+ return (
+ <div className='announcements__item'>
+ <strong className='announcements__item__range'>
+ <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+ {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
+ </strong>
+
+ <Content announcement={announcement} />
+
+ <ReactionsBar
+ reactions={announcement.get('reactions')}
+ announcementId={announcement.get('id')}
+ addReaction={this.props.addReaction}
+ removeReaction={this.props.removeReaction}
+ emojiMap={this.props.emojiMap}
+ />
+
+ <IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
+ </div>
+ );
+ }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcements: ImmutablePropTypes.list,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ fetchAnnouncements: PropTypes.func.isRequired,
+ dismissAnnouncement: PropTypes.func.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ index: 0,
+ };
+
+ componentDidMount () {
+ const { fetchAnnouncements } = this.props;
+ fetchAnnouncements();
+ }
+
+ handleChangeIndex = index => {
+ this.setState({ index: index % this.props.announcements.size });
+ }
+
+ handleNextClick = () => {
+ this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+ }
+
+ handlePrevClick = () => {
+ this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+ }
+
+ render () {
+ const { announcements, intl } = this.props;
+ const { index } = this.state;
+
+ if (announcements.isEmpty()) {
+ return null;
+ }
+
+ return (
+ <div className='announcements'>
+ <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+ <div className='announcements__container'>
+ <ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
+ {announcements.map(announcement => (
+ <Announcement
+ key={announcement.get('id')}
+ announcement={announcement}
+ emojiMap={this.props.emojiMap}
+ dismissAnnouncement={this.props.dismissAnnouncement}
+ addReaction={this.props.addReaction}
+ removeReaction={this.props.removeReaction}
+ intl={intl}
+ />
+ ))}
+ </ReactSwipeableViews>
+
+ <div className='announcements__pagination'>
+ <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+ <span>{index + 1} / {announcements.size}</span>
+ <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
--- /dev/null
+import { connect } from 'react-redux';
+import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'mastodon/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+ announcements: state.getIn(['announcements', 'items']),
+ emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+ fetchAnnouncements: () => dispatch(fetchAnnouncements()),
+ dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+ addReaction: (id, name) => dispatch(addReaction(id, name)),
+ removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
import { connect } from 'react-redux';
-import { fetchTrends } from '../../../actions/trends';
+import { fetchTrends } from 'mastodon/actions/trends';
import Trends from '../components/trends';
const mapStateToProps = state => ({
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { Link } from 'react-router-dom';
+import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
</ColumnHeader>
<StatusListContainer
+ prepend={<AnnouncementsContainer />}
+ alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
- onSwitching={this.handleSwitching}
index={index}
>
{content}
--- /dev/null
+import {
+ ANNOUNCEMENTS_FETCH_REQUEST,
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_FETCH_FAIL,
+ ANNOUNCEMENTS_UPDATE,
+ ANNOUNCEMENTS_DISMISS,
+ ANNOUNCEMENTS_REACTION_UPDATE,
+ ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+} from '../actions/announcements';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ isLoading: false,
+});
+
+const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
+ if (announcement.get('id') === id) {
+ return announcement.update('reactions', reactions => {
+ if (reactions.find(reaction => reaction.get('name') === name)) {
+ return reactions.map(reaction => {
+ if (reaction.get('name') === name) {
+ return updater(reaction);
+ }
+
+ return reaction;
+ });
+ }
+
+ return reactions.push(updater(fromJS({ name, count: 0 })));
+ });
+ }
+
+ return announcement;
+}));
+
+const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
+
+export default function announcementsReducer(state = initialState, action) {
+ switch(action.type) {
+ case ANNOUNCEMENTS_FETCH_REQUEST:
+ return state.set('isLoading', true);
+ case ANNOUNCEMENTS_FETCH_SUCCESS:
+ return state.withMutations(map => {
+ map.set('items', fromJS(action.announcements));
+ map.set('isLoading', false);
+ });
+ case ANNOUNCEMENTS_FETCH_FAIL:
+ return state.set('isLoading', false);
+ case ANNOUNCEMENTS_UPDATE:
+ return state.update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
+ case ANNOUNCEMENTS_DISMISS:
+ return state.update('items', list => list.filterNot(announcement => announcement.get('id') === action.id));
+ case ANNOUNCEMENTS_REACTION_UPDATE:
+ return updateReactionCount(state, action.reaction);
+ case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+ case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+ return addReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+ case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+ return removeReaction(state, action.id, action.name);
+ default:
+ return state;
+ }
+};
import identity_proofs from './identity_proofs';
import trends from './trends';
import missed_updates from './missed_updates';
+import announcements from './announcements';
const reducers = {
+ announcements,
dropdown_menu,
timelines,
meta,
}
}
+.announcements__item__content {
+ word-wrap: break-word;
+
+ .emojione {
+ width: 20px;
+ height: 20px;
+ margin: -3px 0 0;
+ }
+
+ p {
+ margin-bottom: 10px;
+ white-space: pre-wrap;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $highlight-text-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.mention {
+ &:hover {
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+}
+
.status__content.status__content--collapsed {
max-height: 20px * 15; // 15 lines is roughly above 500 characters
}
}
}
}
+
+.announcements {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid $ui-base-color;
+ font-size: 13px;
+ display: flex;
+ align-items: flex-end;
+
+ &__mastodon {
+ width: 124px;
+ flex: 0 0 auto;
+
+ @media screen and (max-width: 124px + 300px) {
+ display: none;
+ }
+ }
+
+ &__container {
+ width: calc(100% - 124px);
+ flex: 0 0 auto;
+ position: relative;
+
+ @media screen and (max-width: 124px + 300px) {
+ width: 100%;
+ }
+ }
+
+ &__item {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 15px;
+ padding-right: 15px + 18px;
+ position: relative;
+
+ &__range {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ &__dismiss-icon {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ }
+ }
+
+ &__pagination {
+ padding: 15px;
+ color: $darker-text-color;
+ position: absolute;
+ bottom: 3px;
+ right: 0;
+ }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+ display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+ width: 100%;
+}
+
+.reactions-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-top: 15px;
+ margin-left: -2px;
+ width: calc(100% - (90px - 33px));
+
+ &__item {
+ flex-shrink: 0;
+ background: lighten($ui-base-color, 12%);
+ border: 0;
+ border-radius: 3px;
+ margin: 2px;
+ cursor: pointer;
+ user-select: none;
+ padding: 0 6px;
+ display: flex;
+ align-items: center;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &__emoji {
+ display: block;
+ margin: 3px 0;
+ width: 16px;
+ height: 16px;
+
+ img {
+ display: block;
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ min-width: auto;
+ min-height: auto;
+ vertical-align: bottom;
+ object-fit: contain;
+ }
+ }
+
+ &__count {
+ display: block;
+ min-width: 9px;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ margin-left: 6px;
+ color: $darker-text-color;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: lighten($ui-base-color, 16%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+
+ &__count {
+ color: lighten($darker-text-color, 4%);
+ }
+ }
+
+ &.active {
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+ background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 90%);
+
+ .reactions-bar__item__count {
+ color: $highlight-text-color;
+ }
+ }
+ }
+
+ .emoji-picker-dropdown {
+ margin: 2px;
+ }
+
+ &:hover .emoji-button {
+ opacity: 0.85;
+ }
+
+ .emoji-button {
+ color: $darker-text-color;
+ margin: 0;
+ font-size: 16px;
+ width: auto;
+ flex-shrink: 0;
+ padding: 0 6px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ opacity: 0.5;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &:hover,
+ &:active,
+ &:focus {
+ opacity: 1;
+ color: lighten($darker-text-color, 4%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+ }
+ }
+
+ &--empty {
+ .emoji-button {
+ padding: 0;
+ }
+ }
+}
}
}
+ .input.datetime .label_input select {
+ display: inline-block;
+ width: auto;
+ flex: 0;
+ }
+
.required abbr {
text-decoration: none;
color: lighten($error-value-color, 12%);
MAX_EXPIRATION = 7.days.freeze
def mention(username, domain)
- Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
+ Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:id, :username, :domain, :url).find_remote(username, domain) }
end
def emoji(shortcodes, domain)
serializer = REST::NotificationSerializer
when :conversation
serializer = REST::ConversationSerializer
+ when :announcement
+ serializer = REST::AnnouncementSerializer
+ when :reaction
+ serializer = REST::ReactionSerializer
else
return
end
records
end
+ def from_text(text)
+ return [] if text.blank?
+
+ text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.map { |(username, domain)| EntityCache.instance.mention(username, domain) }
+ end
+
private
def generate_query_for_search(terms)
--- /dev/null
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcements
+#
+# id :bigint(8) not null, primary key
+# text :text default(""), not null
+# published :boolean default(FALSE), not null
+# all_day :boolean default(FALSE), not null
+# scheduled_at :datetime
+# starts_at :datetime
+# ends_at :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class Announcement < ApplicationRecord
+ after_commit :queue_publish, on: :create
+
+ scope :unpublished, -> { where(published: false) }
+ scope :published, -> { where(published: true) }
+ scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
+ scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.created_at) ASC')) }
+
+ has_many :announcement_mutes, dependent: :destroy
+ has_many :announcement_reactions, dependent: :destroy
+
+ validates :text, presence: true
+ validates :starts_at, presence: true, if: -> { ends_at.present? }
+ validates :ends_at, presence: true, if: -> { starts_at.present? }
+
+ before_validation :set_all_day
+ before_validation :set_starts_at, on: :create
+ before_validation :set_ends_at, on: :create
+
+ def time_range?
+ starts_at.present? && ends_at.present?
+ end
+
+ def mentions
+ @mentions ||= Account.from_text(text)
+ end
+
+ def tags
+ @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
+ end
+
+ def emojis
+ @emojis ||= CustomEmoji.from_text(text)
+ end
+
+ def reactions(account = nil)
+ records = begin
+ scope = announcement_reactions.group(:announcement_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
+
+ if account.nil?
+ scope.select('name, custom_emoji_id, count(*) as count, false as me')
+ else
+ scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
+ end
+ end
+
+ ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
+ records
+ end
+
+ private
+
+ def set_all_day
+ self.all_day = false if starts_at.blank? || ends_at.blank?
+ end
+
+ def set_starts_at
+ self.starts_at = starts_at.change(hour: 0, min: 0, sec: 0) if all_day? && starts_at.present?
+ end
+
+ def set_ends_at
+ self.ends_at = ends_at.change(hour: 23, min: 59, sec: 59) if all_day? && ends_at.present?
+ end
+
+ def queue_publish
+ PublishScheduledAnnouncementWorker.perform_async(id) if scheduled_at.blank?
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class AnnouncementFilter
+ KEYS = %i(
+ published
+ unpublished
+ ).freeze
+
+ attr_reader :params
+
+ def initialize(params)
+ @params = params
+ end
+
+ def results
+ scope = Announcement.unscoped
+
+ params.each do |key, value|
+ next if key.to_s == 'page'
+
+ scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+ end
+
+ scope.chronological
+ end
+
+ private
+
+ def scope_for(key, _value)
+ case key.to_s
+ when 'published'
+ Announcement.published
+ when 'unpublished'
+ Announcement.unpublished
+ else
+ raise "Unknown filter: #{key}"
+ end
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_mutes
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# announcement_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AnnouncementMute < ApplicationRecord
+ belongs_to :account
+ belongs_to :announcement, inverse_of: :announcement_mutes
+
+ validates :account_id, uniqueness: { scope: :announcement_id }
+end
--- /dev/null
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_reactions
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# announcement_id :bigint(8)
+# name :string default(""), not null
+# custom_emoji_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AnnouncementReaction < ApplicationRecord
+ after_commit :queue_publish
+
+ belongs_to :account
+ belongs_to :announcement, inverse_of: :announcement_reactions
+ belongs_to :custom_emoji, optional: true
+
+ validates :name, presence: true
+ validates_with ReactionValidator
+
+ before_validation :set_custom_emoji
+
+ private
+
+ def set_custom_emoji
+ self.custom_emoji = CustomEmoji.local.find_by(disabled: false, shortcode: name) if name.present?
+ end
+
+ def queue_publish
+ PublishAnnouncementReactionWorker.perform_async(announcement_id, name) unless announcement.destroyed?
+ end
+end
# user_id :bigint(8)
# dump_file_name :string
# dump_content_type :string
-# dump_file_size :bigint
# dump_updated_at :datetime
# processed :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
+# dump_file_size :bigint(8)
#
class Backup < ApplicationRecord
#
# Table name: bookmarks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
+# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# status_id :integer not null
#
class Bookmark < ApplicationRecord
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
has_many :conversation_mutes, dependent: :destroy
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
+ has_many :announcement_mutes, dependent: :destroy
end
def follow!(other_account, reblogs: nil, uri: nil)
end
class << self
- def from_text(text, domain)
+ def from_text(text, domain = nil)
return [] if text.blank?
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
--- /dev/null
+# frozen_string_literal: true
+
+class AnnouncementPolicy < ApplicationPolicy
+ def index?
+ staff?
+ end
+
+ def create?
+ admin?
+ end
+
+ def update?
+ admin?
+ end
+
+ def destroy?
+ admin?
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class REST::AnnouncementSerializer < ActiveModel::Serializer
+ attributes :id, :content, :starts_at, :ends_at, :all_day
+
+ has_many :mentions
+ has_many :tags, serializer: REST::StatusSerializer::TagSerializer
+ has_many :emojis, serializer: REST::CustomEmojiSerializer
+ has_many :reactions, serializer: REST::ReactionSerializer
+
+ def id
+ object.id.to_s
+ end
+
+ def content
+ Formatter.instance.linkify(object.text)
+ end
+
+ def reactions
+ object.reactions(current_user&.account)
+ end
+
+ class AccountSerializer < ActiveModel::Serializer
+ attributes :id, :username, :url, :acct
+
+ def id
+ object.id.to_s
+ end
+
+ def url
+ ActivityPub::TagManager.instance.url_for(object)
+ end
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class REST::ReactionSerializer < ActiveModel::Serializer
+ include RoutingHelper
+
+ attributes :name, :count
+
+ attribute :me, if: :current_user?
+ attribute :url, if: :custom_emoji?
+ attribute :static_url, if: :custom_emoji?
+
+ def count
+ object.respond_to?(:count) ? object.count : 0
+ end
+
+ def current_user?
+ !current_user.nil?
+ end
+
+ def custom_emoji?
+ object.custom_emoji.present?
+ end
+
+ def url
+ full_asset_url(object.custom_emoji.image.url)
+ end
+
+ def static_url
+ full_asset_url(object.custom_emoji.image.url(:static))
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class ReactionValidator < ActiveModel::Validator
+ SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
+
+ def validate(reaction)
+ return if reaction.name.blank? || reaction.custom_emoji_id.present?
+
+ reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) unless unicode_emoji?(reaction.name)
+ end
+
+ private
+
+ def unicode_emoji?(name)
+ SUPPORTED_EMOJIS.include?(name)
+ end
+end
--- /dev/null
+%tr
+ %td
+ = truncate(announcement.text)
+ %td
+ = time_range(announcement) if announcement.time_range?
+ %td
+ - if announcement.scheduled_at.present?
+ = fa_icon('clock-o') if announcement.scheduled_at > Time.now.utc
+ = l(announcement.scheduled_at)
+ - else
+ = l(announcement.created_at)
+ %td
+ = table_link_to 'pencil', t('generic.edit'), edit_admin_announcement_path(announcement) if can?(:update, announcement)
+ = table_link_to 'trash', t('generic.delete'), admin_announcement_path(announcement), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, announcement)
--- /dev/null
+- content_for :page_title do
+ = t('.title')
+
+= simple_form_for @announcement, url: admin_announcement_path(@announcement) do |f|
+ = render 'shared/error_messages', object: @announcement
+
+ .fields-group
+ = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+ = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :all_day, as: :boolean, wrapper: :with_label
+
+ .fields-group
+ = f.input :text, wrapper: :with_block_label
+
+ - if @announcement.scheduled_at.present? && !@announcement.published?
+ .fields-group
+ = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+ .actions
+ = f.button :button, t('generic.save_changes'), type: :submit
--- /dev/null
+- content_for :page_title do
+ = t('admin.announcements.title')
+
+- content_for :heading_actions do
+ = link_to t('admin.announcements.new.title'), new_admin_announcement_path, class: 'button'
+
+.filters
+ .filter-subset
+ %strong= t('admin.relays.status')
+ %ul
+ %li= filter_link_to t('generic.all'), published: nil, unpublished: nil
+ %li= filter_link_to safe_join([t('admin.announcements.live'), "(#{number_with_delimiter(Announcement.published.count)})"], ' '), published: '1', unpublished: nil
+
+- if @announcements.empty?
+ %div.muted-hint.center-text
+ = t 'admin.announcements.empty'
+- else
+ .table-wrapper
+ %table.table
+ %thead
+ %tr
+ %th= t('simple_form.labels.announcement.text')
+ %th= t('admin.announcements.time_range')
+ %th= t('admin.announcements.published')
+ %th
+ %tbody
+ = render partial: 'announcement', collection: @announcements
+
+= paginate @announcements
+
--- /dev/null
+- content_for :page_title do
+ = t('.title')
+
+= simple_form_for @announcement, url: admin_announcements_path do |f|
+ = render 'shared/error_messages', object: @announcement
+
+ .fields-group
+ = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+ = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :all_day, as: :boolean, wrapper: :with_label
+
+ .fields-group
+ = f.input :text, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+ .actions
+ = f.button :button, t('.create'), type: :submit
--- /dev/null
+# frozen_string_literal: true
+
+class PublishAnnouncementReactionWorker
+ include Sidekiq::Worker
+ include Redisable
+
+ def perform(announcement_id, name)
+ announcement = Announcement.find(announcement_id)
+
+ reaction, = announcement.announcement_reactions.where(name: name).group(:announcement_id, :name, :custom_emoji_id).select('name, custom_emoji_id, count(*) as count, false as me')
+ reaction ||= announcement.announcement_reactions.new(name: name)
+
+ payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id }
+ payload = Oj.dump(event: :'announcement.reaction', payload: payload)
+
+ Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+ redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+ end
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class PublishScheduledAnnouncementWorker
+ include Sidekiq::Worker
+ include Redisable
+
+ def perform(announcement_id)
+ announcement = Announcement.find(announcement_id)
+ announcement.update(published: true)
+
+ payload = InlineRenderer.render(announcement, nil, :announcement)
+ payload = Oj.dump(event: :announcement, payload: payload)
+
+ Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+ redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+ end
+ end
+end
sidekiq_options unique: :until_executed, retry: 0
def perform
+ publish_scheduled_statuses!
+ publish_scheduled_announcements!
+ unpublish_expired_announcements!
+ end
+
+ private
+
+ def publish_scheduled_statuses!
due_statuses.find_each do |scheduled_status|
PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at, scheduled_status.id)
end
end
- private
-
def due_statuses
ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
end
+
+ def publish_scheduled_announcements!
+ due_announcements.find_each do |announcement|
+ PublishScheduledAnnouncementWorker.perform_at(announcement.scheduled_at, announcement.id)
+ end
+ end
+
+ def due_announcements
+ Announcement.unpublished.where('scheduled_at IS NOT NULL AND scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
+ end
+
+ def unpublish_expired_announcements!
+ expired_announcements.in_batches.update_all(published: false)
+ end
+
+ def expired_announcements
+ Announcement.published.where('ends_at IS NOT NULL AND ends_at <= ?', Time.now.utc)
+ end
end
b.use :html5
b.use :label
b.use :hint, wrap_with: { tag: :span, class: :hint }
- b.use :input
+ b.use :input, wrap_with: { tag: :div, class: :label_input }
b.use :error, wrap_with: { tag: :span, class: :error }
end
change_email_user: "%{name} changed the e-mail address of user %{target}"
confirm_user: "%{name} confirmed e-mail address of user %{target}"
create_account_warning: "%{name} sent a warning to %{target}"
+ create_announcement: "%{name} created new announcement %{target}"
create_custom_emoji: "%{name} uploaded new emoji %{target}"
create_domain_allow: "%{name} whitelisted domain %{target}"
create_domain_block: "%{name} blocked domain %{target}"
create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
demote_user: "%{name} demoted user %{target}"
+ destroy_announcement: "%{name} deleted announcement %{target}"
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
destroy_domain_allow: "%{name} removed domain %{target} from whitelist"
destroy_domain_block: "%{name} unblocked domain %{target}"
unassigned_report: "%{name} unassigned report %{target}"
unsilence_account: "%{name} unsilenced %{target}'s account"
unsuspend_account: "%{name} unsuspended %{target}'s account"
+ update_announcement: "%{name} updated announcement %{target}"
update_custom_emoji: "%{name} updated emoji %{target}"
update_status: "%{name} updated status by %{target}"
deleted_status: "(deleted status)"
title: Audit log
+ announcements:
+ edit:
+ title: Edit announcement
+ empty: No announcements found.
+ live: Live
+ new:
+ create: Create announcement
+ title: New announcement
+ published: Published
+ time_range: Time range
+ title: Announcements
custom_emojis:
assign_category: Assign category
by_domain: Domain
hint_html: "<strong>Tip:</strong> We won't ask you for your password again for the next hour."
invalid_password: Invalid password
prompt: Confirm password to continue
+ date:
+ formats:
+ default: "%b %d, %Y"
datetime:
distance_in_words:
about_x_hours: "%{count}h"
all: All
changes_saved_msg: Changes successfully saved!
copy: Copy
+ delete: Delete
+ edit: Edit
no_batch_actions_available: No batch actions available on this page
order_by: Order by
save_changes: Save changes
other: Other
posting_defaults: Posting defaults
public_timelines: Public timelines
+ reactions:
+ errors:
+ unrecognized_emoji: is not a recognized emoji
relationships:
activity: Account activity
dormant: Dormant
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
type_html: Choose what to do with <strong>%{acct}</strong>
warning_preset_id: Optional. You can still add custom text to end of the preset
+ announcement:
+ all_day: When checked, only the dates of the time range will be displayed
+ ends_at: Optional. Announcement will be automatically unpublished at this time
+ scheduled_at: Leave blank to publish the announcement immediately
+ starts_at: Optional. In case your announcement is bound to a specific time range
+ text: You can use toot syntax. Please be mindful of the space the announcement will take up on the user's screen
defaults:
autofollow: People who sign up through the invite will automatically follow you
avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
silence: Silence
suspend: Suspend and irreversibly delete account data
warning_preset_id: Use a warning preset
+ announcement:
+ all_day: All-day event
+ ends_at: End of event
+ scheduled_at: Schedule publication
+ starts_at: Begin of event
+ text: Announcement
defaults:
autofollow: Invite to follow your account
avatar: Avatar
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url
s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings}
+ s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
get :edit
end
end
+
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
resources :action_logs, only: [:index]
resources :warning_presets, except: [:new]
+ resources :announcements, except: [:show]
+
resource :settings, only: [:edit, :update]
resources :invites, only: [:index, :create, :destroy] do
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
resources :preferences, only: [:index]
+ resources :announcements, only: [:index] do
+ scope module: :announcements do
+ resources :reactions, only: [:update, :destroy]
+ end
+
+ member do
+ post :dismiss
+ end
+ end
+
resources :conversations, only: [:index, :destroy] do
member do
post :read
--- /dev/null
+class CreateAnnouncements < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcements do |t|
+ t.text :text, null: false, default: ''
+
+ t.boolean :published, null: false, default: false
+ t.boolean :all_day, null: false, default: false
+
+ t.datetime :scheduled_at
+ t.datetime :starts_at
+ t.datetime :ends_at
+
+ t.timestamps
+ end
+ end
+end
--- /dev/null
+class CreateAnnouncementMutes < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcement_mutes do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade, index: false }
+ t.belongs_to :announcement, foreign_key: { on_delete: :cascade }
+
+ t.timestamps
+ end
+
+ add_index :announcement_mutes, [:account_id, :announcement_id], unique: true
+ end
+end
--- /dev/null
+class CreateAnnouncementReactions < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcement_reactions do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade, index: false }
+ t.belongs_to :announcement, foreign_key: { on_delete: :cascade }
+
+ t.string :name, null: false, default: ''
+ t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade }
+
+ t.timestamps
+ end
+
+ add_index :announcement_reactions, [:account_id, :announcement_id, :name], unique: true, name: :index_announcement_reactions_on_account_id_and_announcement_id
+ end
+end
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
end
+ create_table "announcement_mutes", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "announcement_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "announcement_id"], name: "index_announcement_mutes_on_account_id_and_announcement_id", unique: true
+ t.index ["account_id"], name: "index_announcement_mutes_on_account_id"
+ t.index ["announcement_id"], name: "index_announcement_mutes_on_announcement_id"
+ end
+
+ create_table "announcement_reactions", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "announcement_id"
+ t.string "name", default: "", null: false
+ t.bigint "custom_emoji_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "announcement_id", "name"], name: "index_announcement_reactions_on_account_id_and_announcement_id", unique: true
+ t.index ["account_id"], name: "index_announcement_reactions_on_account_id"
+ t.index ["announcement_id"], name: "index_announcement_reactions_on_announcement_id"
+ t.index ["custom_emoji_id"], name: "index_announcement_reactions_on_custom_emoji_id"
+ end
+
+ create_table "announcements", force: :cascade do |t|
+ t.text "text", default: "", null: false
+ t.boolean "published", default: false, null: false
+ t.boolean "all_day", default: false, null: false
+ t.datetime "scheduled_at"
+ t.datetime "starts_at"
+ t.datetime "ends_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "backups", force: :cascade do |t|
t.bigint "user_id"
t.string "dump_file_name"
t.string "dump_content_type"
- t.bigint "dump_file_size"
t.datetime "dump_updated_at"
t.boolean "processed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.bigint "dump_file_size"
end
create_table "blocks", force: :cascade do |t|
add_foreign_key "account_warnings", "accounts", on_delete: :nullify
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_mutes", "announcements", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade
add_foreign_key "backups", "users", on_delete: :nullify
add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
task :set_annotation_options do
Annotate.set_defaults(
'routes' => 'false',
+ 'models' => 'true',
'position_in_routes' => 'before',
'position_in_class' => 'before',
'position_in_test' => 'before',
--- /dev/null
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::Announcements::ReactionsController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:scopes) { 'write:favourites' }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+ let!(:announcement) { Fabricate(:announcement) }
+
+ describe 'PUT #update' do
+ context 'without token' do
+ it 'returns http unauthorized' do
+ put :update, params: { announcement_id: announcement.id, id: '😂' }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ put :update, params: { announcement_id: announcement.id, id: '😂' }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'creates reaction' do
+ expect(announcement.announcement_reactions.find_by(name: '😂', account: user.account)).to_not be_nil
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ before do
+ announcement.announcement_reactions.create!(account: user.account, name: '😂')
+ end
+
+ context 'without token' do
+ it 'returns http unauthorized' do
+ delete :destroy, params: { announcement_id: announcement.id, id: '😂' }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ delete :destroy, params: { announcement_id: announcement.id, id: '😂' }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'creates reaction' do
+ expect(announcement.announcement_reactions.find_by(name: '😂', account: user.account)).to be_nil
+ end
+ end
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::AnnouncementsController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:scopes) { 'read' }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+ let!(:announcement) { Fabricate(:announcement) }
+
+ describe 'GET #index' do
+ context 'without token' do
+ it 'returns http unprocessable entity' do
+ get :index
+ expect(response).to have_http_status :unprocessable_entity
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ get :index
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+
+ describe 'POST #dismiss' do
+ context 'without token' do
+ it 'returns http unauthorized' do
+ post :dismiss, params: { id: announcement.id }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ let(:scopes) { 'write:accounts' }
+
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ post :dismiss, params: { id: announcement.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'dismisses announcement' do
+ expect(announcement.announcement_mutes.find_by(account: user.account)).to_not be_nil
+ end
+ end
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::TrendsController, type: :controller do
+ render_views
+
+ describe 'GET #index' do
+ before do
+ allow(TrendingTags).to receive(:get).and_return(Fabricate.times(10, :tag))
+ get :index
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+ end
+end
--- /dev/null
+Fabricator(:announcement) do
+ text { Faker::Lorem.paragraph(sentence_count: 2) }
+ published true
+ starts_at nil
+ ends_at nil
+end
--- /dev/null
+Fabricator(:announcement_mute) do
+ account
+ announcement
+end
--- /dev/null
+Fabricator(:announcement_reaction) do
+ account
+ announcement
+ name '🌿'
+end
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe AnnouncementMute, type: :model do
+end
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe AnnouncementReaction, type: :model do
+end
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe Announcement, type: :model do
+end