From c0006a004d0e58bb3ad356759c17e60f28975b61 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 23 Jan 2020 20:33:20 +0100 Subject: Change followers page to relationships page in admin UI (#12927) Allow browsing and filtering all relationships instead of just followers, unify the codebase with the user-facing relationship manager, add ability to see who the user invited --- app/controllers/admin/followers_controller.rb | 18 --------- app/controllers/admin/relationships_controller.rb | 25 ++++++++++++ app/controllers/relationships_controller.rb | 46 ++--------------------- 3 files changed, 28 insertions(+), 61 deletions(-) delete mode 100644 app/controllers/admin/followers_controller.rb create mode 100644 app/controllers/admin/relationships_controller.rb (limited to 'app/controllers') diff --git a/app/controllers/admin/followers_controller.rb b/app/controllers/admin/followers_controller.rb deleted file mode 100644 index d826f47c5..000000000 --- a/app/controllers/admin/followers_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Admin - class FollowersController < BaseController - before_action :set_account - - PER_PAGE = 40 - - def index - authorize :account, :index? - @followers = @account.followers.local.recent.page(params[:page]).per(PER_PAGE) - end - - def set_account - @account = Account.find(params[:account_id]) - end - end -end diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb new file mode 100644 index 000000000..07d121340 --- /dev/null +++ b/app/controllers/admin/relationships_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Admin + class RelationshipsController < BaseController + before_action :set_account + + PER_PAGE = 40 + + def index + authorize :account, :index? + + @accounts = RelationshipFilter.new(@account, filter_params).results.page(params[:page]).per(PER_PAGE) + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def filter_params + params.slice(RelationshipFilter::KEYS).permit(RelationshipFilter::KEYS) + end + end +end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index 9d0be4a00..0835758f2 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -19,53 +19,13 @@ class RelationshipsController < ApplicationController rescue ActionController::ParameterMissing # Do nothing ensure - redirect_to relationships_path(current_params) + redirect_to relationships_path(filter_params) end private def set_accounts - @accounts = relationships_scope.page(params[:page]).per(40) - end - - def relationships_scope - scope = begin - if following_relationship? - current_account.following.eager_load(:account_stat).reorder(nil) - else - current_account.followers.eager_load(:account_stat).reorder(nil) - end - end - - scope.merge!(Follow.recent) if params[:order].blank? || params[:order] == 'recent' - scope.merge!(Account.by_recent_status) if params[:order] == 'active' - scope.merge!(mutual_relationship_scope) if mutual_relationship? - scope.merge!(moved_account_scope) if params[:status] == 'moved' - scope.merge!(primary_account_scope) if params[:status] == 'primary' - scope.merge!(by_domain_scope) if params[:by_domain].present? - scope.merge!(dormant_account_scope) if params[:activity] == 'dormant' - - scope - end - - def mutual_relationship_scope - Account.where(id: current_account.following) - end - - def moved_account_scope - Account.where.not(moved_to_account_id: nil) - end - - def primary_account_scope - Account.where(moved_to_account_id: nil) - end - - def dormant_account_scope - AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago))) - end - - def by_domain_scope - Account.where(domain: params[:by_domain]) + @accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40) end def form_account_batch_params @@ -84,7 +44,7 @@ class RelationshipsController < ApplicationController params[:relationship] == 'followed_by' end - def current_params + def filter_params params.slice(:page, *RelationshipFilter::KEYS).permit(:page, *RelationshipFilter::KEYS) end -- cgit From f52c988e12e464e7baefc2fdb48ddf4a95584664 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 23 Jan 2020 22:00:13 +0100 Subject: Add announcements (#12662) * Add announcements Fix #11006 * Add reactions to announcements * Add admin UI for announcements * Add unit tests * Fix issues - Add `with_dismissed` param to announcements API - Fix end date not being formatted when time range is given - Fix announcement delete causing reactions to send streaming updates - Fix announcements container growing too wide and mascot too small - Fix `all_day` being settable when no time range is given - Change text "Update" to "Announcement" * Fix scheduler unpublishing announcements before they are due * Fix filter params not being passed to announcements filter --- app/controllers/admin/announcements_controller.rb | 69 ++++ app/controllers/api/base_controller.rb | 2 +- .../api/v1/announcements/reactions_controller.rb | 29 ++ app/controllers/api/v1/announcements_controller.rb | 33 ++ app/helpers/admin/action_logs_helper.rb | 8 + app/helpers/admin/announcements_helper.rb | 11 + app/helpers/admin/filter_helper.rb | 1 + app/javascript/images/elephant_ui_plane.svg | 2 +- app/javascript/mastodon/actions/announcements.js | 133 +++++++ .../mastodon/actions/importer/normalizer.js | 10 +- app/javascript/mastodon/actions/notifications.js | 3 +- app/javascript/mastodon/actions/streaming.js | 11 +- app/javascript/mastodon/actions/timelines.js | 2 +- .../mastodon/components/error_boundary.js | 2 +- .../compose/components/emoji_picker_dropdown.js | 7 +- .../getting_started/components/announcements.js | 395 +++++++++++++++++++++ .../containers/announcements_container.js | 21 ++ .../getting_started/containers/trends_container.js | 2 +- .../mastodon/features/home_timeline/index.js | 3 + .../mastodon/features/ui/components/media_modal.js | 1 - app/javascript/mastodon/reducers/announcements.js | 72 ++++ app/javascript/mastodon/reducers/index.js | 2 + app/javascript/styles/mastodon/components.scss | 213 +++++++++++ app/javascript/styles/mastodon/forms.scss | 6 + app/lib/entity_cache.rb | 2 +- app/lib/inline_renderer.rb | 4 + app/models/account.rb | 6 + app/models/announcement.rb | 85 +++++ app/models/announcement_filter.rb | 39 ++ app/models/announcement_mute.rb | 19 + app/models/announcement_reaction.rb | 37 ++ app/models/backup.rb | 2 +- app/models/bookmark.rb | 6 +- app/models/concerns/account_interactions.rb | 1 + app/models/custom_emoji.rb | 2 +- app/policies/announcement_policy.rb | 19 + app/serializers/rest/announcement_serializer.rb | 34 ++ app/serializers/rest/reaction_serializer.rb | 31 ++ app/validators/reaction_validator.rb | 17 + .../admin/announcements/_announcement.html.haml | 14 + app/views/admin/announcements/edit.html.haml | 22 ++ app/views/admin/announcements/index.html.haml | 30 ++ app/views/admin/announcements/new.html.haml | 21 ++ .../publish_announcement_reaction_worker.rb | 22 ++ .../publish_scheduled_announcement_worker.rb | 18 + .../scheduler/scheduled_statuses_scheduler.rb | 28 +- config/initializers/simple_form.rb | 2 +- config/locales/en.yml | 22 ++ config/locales/simple_form.en.yml | 12 + config/navigation.rb | 1 + config/routes.rb | 13 + db/migrate/20191218153258_create_announcements.rb | 16 + .../20200113125135_create_announcement_mutes.rb | 12 + ...20200114113335_create_announcement_reactions.rb | 15 + db/schema.rb | 41 ++- lib/tasks/auto_annotate_models.rake | 1 + .../v1/announcements/reactions_controller_spec.rb | 65 ++++ .../api/v1/announcements_controller_spec.rb | 59 +++ spec/controllers/api/v1/trends_controller_spec.rb | 18 + spec/fabricators/announcement_fabricator.rb | 6 + spec/fabricators/announcement_mute_fabricator.rb | 4 + .../announcement_reaction_fabricator.rb | 5 + spec/models/announcement_mute_spec.rb | 4 + spec/models/announcement_reaction_spec.rb | 4 + spec/models/announcement_spec.rb | 4 + 65 files changed, 1779 insertions(+), 22 deletions(-) create mode 100644 app/controllers/admin/announcements_controller.rb create mode 100644 app/controllers/api/v1/announcements/reactions_controller.rb create mode 100644 app/controllers/api/v1/announcements_controller.rb create mode 100644 app/helpers/admin/announcements_helper.rb create mode 100644 app/javascript/mastodon/actions/announcements.js create mode 100644 app/javascript/mastodon/features/getting_started/components/announcements.js create mode 100644 app/javascript/mastodon/features/getting_started/containers/announcements_container.js create mode 100644 app/javascript/mastodon/reducers/announcements.js create mode 100644 app/models/announcement.rb create mode 100644 app/models/announcement_filter.rb create mode 100644 app/models/announcement_mute.rb create mode 100644 app/models/announcement_reaction.rb create mode 100644 app/policies/announcement_policy.rb create mode 100644 app/serializers/rest/announcement_serializer.rb create mode 100644 app/serializers/rest/reaction_serializer.rb create mode 100644 app/validators/reaction_validator.rb create mode 100644 app/views/admin/announcements/_announcement.html.haml create mode 100644 app/views/admin/announcements/edit.html.haml create mode 100644 app/views/admin/announcements/index.html.haml create mode 100644 app/views/admin/announcements/new.html.haml create mode 100644 app/workers/publish_announcement_reaction_worker.rb create mode 100644 app/workers/publish_scheduled_announcement_worker.rb create mode 100644 db/migrate/20191218153258_create_announcements.rb create mode 100644 db/migrate/20200113125135_create_announcement_mutes.rb create mode 100644 db/migrate/20200114113335_create_announcement_reactions.rb create mode 100644 spec/controllers/api/v1/announcements/reactions_controller_spec.rb create mode 100644 spec/controllers/api/v1/announcements_controller_spec.rb create mode 100644 spec/controllers/api/v1/trends_controller_spec.rb create mode 100644 spec/fabricators/announcement_fabricator.rb create mode 100644 spec/fabricators/announcement_mute_fabricator.rb create mode 100644 spec/fabricators/announcement_reaction_fabricator.rb create mode 100644 spec/models/announcement_mute_spec.rb create mode 100644 spec/models/announcement_reaction_spec.rb create mode 100644 spec/models/announcement_spec.rb (limited to 'app/controllers') diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb new file mode 100644 index 000000000..02198f0b5 --- /dev/null +++ b/app/controllers/admin/announcements_controller.rb @@ -0,0 +1,69 @@ +# 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 diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 144fdd6ac..68bf425f4 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -85,7 +85,7 @@ class Api::BaseController < ApplicationController 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! diff --git a/app/controllers/api/v1/announcements/reactions_controller.rb b/app/controllers/api/v1/announcements/reactions_controller.rb new file mode 100644 index 000000000..e4a72e595 --- /dev/null +++ b/app/controllers/api/v1/announcements/reactions_controller.rb @@ -0,0 +1,29 @@ +# 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 diff --git a/app/controllers/api/v1/announcements_controller.rb b/app/controllers/api/v1/announcements_controller.rb new file mode 100644 index 000000000..6724fac2e --- /dev/null +++ b/app/controllers/api/v1/announcements_controller.rb @@ -0,0 +1,33 @@ +# 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 diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 608a99dd5..6bc75aa56 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -22,6 +22,8 @@ module Admin::ActionLogsHelper 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 @@ -52,6 +54,8 @@ module Admin::ActionLogsHelper 'pencil' when 'AccountWarning' 'warning' + when 'Announcement' + 'bullhorn' end end @@ -94,6 +98,8 @@ module Admin::ActionLogsHelper 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 @@ -111,6 +117,8 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_status') end + when 'Announcement' + "##{attributes['id']}" end end end diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb new file mode 100644 index 000000000..0c053ddec --- /dev/null +++ b/app/helpers/admin/announcements_helper.rb @@ -0,0 +1,11 @@ +# 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 diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 130686a02..6ab92939d 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -9,6 +9,7 @@ module Admin::FilterHelper InstanceFilter::KEYS, InviteFilter::KEYS, RelationshipFilter::KEYS, + AnnouncementFilter::KEYS, ].flatten.freeze def filter_link_to(text, link_to_params, link_class_params = link_to_params) diff --git a/app/javascript/images/elephant_ui_plane.svg b/app/javascript/images/elephant_ui_plane.svg index a2624d170..ca675c9eb 100644 --- a/app/javascript/images/elephant_ui_plane.svg +++ b/app/javascript/images/elephant_ui_plane.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js new file mode 100644 index 000000000..c65bc052e --- /dev/null +++ b/app/javascript/mastodon/actions/announcements.js @@ -0,0 +1,133 @@ +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, +}); diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 78f321da4..f7cbe4c1c 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -76,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) { export function normalizePoll(poll) { const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(normalPoll); normalPoll.options = poll.options.map((option, index) => ({ @@ -87,3 +86,12 @@ export function normalizePoll(poll) { return normalPoll; } + +export function normalizeAnnouncement(announcement) { + const normalAnnouncement = { ...announcement }; + const emojiMap = makeEmojiMap(normalAnnouncement); + + normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + + return normalAnnouncement; +} diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 798f9b37e..8a066b896 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) { 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(); }); }; @@ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) { type: NOTIFICATIONS_EXPAND_FAIL, error, skipLoading: !isLoadingMore, + skipAlert: !isLoadingMore, }; }; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index c678e9393..ac325f74c 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -8,6 +8,7 @@ import { } 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'; @@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, 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; } }, }; @@ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, } const refreshHomeTimelineAndNotification = (dispatch, done) => { - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); }; export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index bc2ac5e82..054668655 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { 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(); }); }; diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js index 800b1c270..4e1c882e2 100644 --- a/app/javascript/mastodon/components/error_boundary.js +++ b/app/javascript/mastodon/components/error_boundary.js @@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {

-

Mastodon v{version} 路

+

Mastodon v{version} 路

); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e57c3c20c..582bb0d39 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + button: PropTypes.node, }; state = { @@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent { } 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 (
- 馃檪 + />}
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js new file mode 100644 index 000000000..ee444e3f0 --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -0,0 +1,395 @@ +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 ( +
+ ); + } + +} + +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 ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } 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 ( + + ); + } + +} + +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 ( +
+ {visibleReactions.map(reaction => ( + + ))} + + } /> +
+ ); + } + +} + +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 ( +
+ + + {hasTimeRange && - } + + + + + + + +
+ ); + } + +} + +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 ( +
+ + +
+ + {announcements.map(announcement => ( + + ))} + + +
+ + {index + 1} / {announcements.size} + +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/containers/announcements_container.js b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js new file mode 100644 index 000000000..b10d1d4ce --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js @@ -0,0 +1,21 @@ +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); diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js index 1df3fb4fe..7a5268780 100644 --- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js +++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { fetchTrends } from '../../../actions/trends'; +import { fetchTrends } from 'mastodon/actions/trends'; import Trends from '../components/trends'; const mapStateToProps = state => ({ diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 1cafb88ed..b7f9d5095 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; 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' }, @@ -113,6 +114,8 @@ class HomeTimeline extends React.PureComponent { } + alwaysPrepend trackScroll={!pinned} scrollKey={`home_timeline-${columnId}`} onLoadMore={this.handleLoadMore} diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index a785551c0..d7f97f210 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent { style={swipeableViewsStyle} containerStyle={containerStyle} onChangeIndex={this.handleSwipe} - onSwitching={this.handleSwitching} index={index} > {content} diff --git a/app/javascript/mastodon/reducers/announcements.js b/app/javascript/mastodon/reducers/announcements.js new file mode 100644 index 000000000..aa674e516 --- /dev/null +++ b/app/javascript/mastodon/reducers/announcements.js @@ -0,0 +1,72 @@ +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; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index b8d608888..b9817cd38 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -34,8 +34,10 @@ import polls from './polls'; 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, diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 94671c350..922d48ad7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -859,6 +859,44 @@ } } +.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 } @@ -6581,3 +6619,178 @@ noscript { } } } + +.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; + } + } +} diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 8965ce675..65cefbd7c 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -222,6 +222,12 @@ code { } } + .input.datetime .label_input select { + display: inline-block; + width: auto; + flex: 0; + } + .required abbr { text-decoration: none; color: lighten($error-value-color, 12%); diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb index 8fff544a0..35a3773d2 100644 --- a/app/lib/entity_cache.rb +++ b/app/lib/entity_cache.rb @@ -8,7 +8,7 @@ class EntityCache 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) diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 761a8822d..27e334a4d 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -15,6 +15,10 @@ class InlineRenderer serializer = REST::NotificationSerializer when :conversation serializer = REST::ConversationSerializer + when :announcement + serializer = REST::AnnouncementSerializer + when :reaction + serializer = REST::ReactionSerializer else return end diff --git a/app/models/account.rb b/app/models/account.rb index 1e8abe6ec..da6f51a9c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -476,6 +476,12 @@ class Account < ApplicationRecord 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) diff --git a/app/models/announcement.rb b/app/models/announcement.rb new file mode 100644 index 000000000..4da9f94d6 --- /dev/null +++ b/app/models/announcement.rb @@ -0,0 +1,85 @@ +# 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 diff --git a/app/models/announcement_filter.rb b/app/models/announcement_filter.rb new file mode 100644 index 000000000..950852460 --- /dev/null +++ b/app/models/announcement_filter.rb @@ -0,0 +1,39 @@ +# 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 diff --git a/app/models/announcement_mute.rb b/app/models/announcement_mute.rb new file mode 100644 index 000000000..46fda2f5d --- /dev/null +++ b/app/models/announcement_mute.rb @@ -0,0 +1,19 @@ +# 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 diff --git a/app/models/announcement_reaction.rb b/app/models/announcement_reaction.rb new file mode 100644 index 000000000..d22771034 --- /dev/null +++ b/app/models/announcement_reaction.rb @@ -0,0 +1,37 @@ +# 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 diff --git a/app/models/backup.rb b/app/models/backup.rb index 8eeb1748a..d242fd62c 100644 --- a/app/models/backup.rb +++ b/app/models/backup.rb @@ -7,11 +7,11 @@ # 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 diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 01dc48ee7..916261a17 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -3,11 +3,11 @@ # # 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 diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index f27d39483..14bcf7bb1 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -84,6 +84,7 @@ module AccountInteractions 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) diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 0dacaf654..d177cf281 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -67,7 +67,7 @@ class CustomEmoji < ApplicationRecord 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 diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb new file mode 100644 index 000000000..0a4e4575c --- /dev/null +++ b/app/policies/announcement_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AnnouncementPolicy < ApplicationPolicy + def index? + staff? + end + + def create? + admin? + end + + def update? + admin? + end + + def destroy? + admin? + end +end diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb new file mode 100644 index 000000000..924d87b34 --- /dev/null +++ b/app/serializers/rest/announcement_serializer.rb @@ -0,0 +1,34 @@ +# 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 diff --git a/app/serializers/rest/reaction_serializer.rb b/app/serializers/rest/reaction_serializer.rb new file mode 100644 index 000000000..1a5dca018 --- /dev/null +++ b/app/serializers/rest/reaction_serializer.rb @@ -0,0 +1,31 @@ +# 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 diff --git a/app/validators/reaction_validator.rb b/app/validators/reaction_validator.rb new file mode 100644 index 000000000..de0f2c94b --- /dev/null +++ b/app/validators/reaction_validator.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml new file mode 100644 index 000000000..75768c7ba --- /dev/null +++ b/app/views/admin/announcements/_announcement.html.haml @@ -0,0 +1,14 @@ +%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) diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml new file mode 100644 index 000000000..c5c605e93 --- /dev/null +++ b/app/views/admin/announcements/edit.html.haml @@ -0,0 +1,22 @@ +- 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 diff --git a/app/views/admin/announcements/index.html.haml b/app/views/admin/announcements/index.html.haml new file mode 100644 index 000000000..634f586fb --- /dev/null +++ b/app/views/admin/announcements/index.html.haml @@ -0,0 +1,30 @@ +- 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 + diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml new file mode 100644 index 000000000..a5298c5f6 --- /dev/null +++ b/app/views/admin/announcements/new.html.haml @@ -0,0 +1,21 @@ +- 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 diff --git a/app/workers/publish_announcement_reaction_worker.rb b/app/workers/publish_announcement_reaction_worker.rb new file mode 100644 index 000000000..6f3b6dc5b --- /dev/null +++ b/app/workers/publish_announcement_reaction_worker.rb @@ -0,0 +1,22 @@ +# 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 diff --git a/app/workers/publish_scheduled_announcement_worker.rb b/app/workers/publish_scheduled_announcement_worker.rb new file mode 100644 index 000000000..4b2014e34 --- /dev/null +++ b/app/workers/publish_scheduled_announcement_worker.rb @@ -0,0 +1,18 @@ +# 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 diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb index 1772a246b..4262f1d01 100644 --- a/app/workers/scheduler/scheduled_statuses_scheduler.rb +++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb @@ -6,14 +6,38 @@ class Scheduler::ScheduledStatusesScheduler 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 diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb index 964526819..3dc48ef08 100644 --- a/config/initializers/simple_form.rb +++ b/config/initializers/simple_form.rb @@ -98,7 +98,7 @@ SimpleForm.setup do |config| 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 2bd84c264..c4e846354 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -198,11 +198,13 @@ en: 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}" @@ -224,10 +226,22 @@ en: 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 @@ -657,6 +671,9 @@ en: hint_html: "Tip: 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" @@ -758,6 +775,8 @@ en: 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 @@ -930,6 +949,9 @@ en: 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 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 66f518c1b..f050ec8a3 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -14,6 +14,12 @@ en: text_html: Optional. You can use toot syntax. You can add warning presets to save time type_html: Choose what to do with %{acct} 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 @@ -83,6 +89,12 @@ en: 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 diff --git a/config/navigation.rb b/config/navigation.rb index eebd4f75e..8fd296d5a 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -46,6 +46,7 @@ SimpleNavigation::Configuration.run do |navigation| 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? } diff --git a/config/routes.rb b/config/routes.rb index f79af192d..da7bf6f88 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -173,9 +173,12 @@ Rails.application.routes.draw do 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 @@ -317,6 +320,16 @@ Rails.application.routes.draw 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 diff --git a/db/migrate/20191218153258_create_announcements.rb b/db/migrate/20191218153258_create_announcements.rb new file mode 100644 index 000000000..58e143c92 --- /dev/null +++ b/db/migrate/20191218153258_create_announcements.rb @@ -0,0 +1,16 @@ +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 diff --git a/db/migrate/20200113125135_create_announcement_mutes.rb b/db/migrate/20200113125135_create_announcement_mutes.rb new file mode 100644 index 000000000..c588e7fcd --- /dev/null +++ b/db/migrate/20200113125135_create_announcement_mutes.rb @@ -0,0 +1,12 @@ +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 diff --git a/db/migrate/20200114113335_create_announcement_reactions.rb b/db/migrate/20200114113335_create_announcement_reactions.rb new file mode 100644 index 000000000..226c81a18 --- /dev/null +++ b/db/migrate/20200114113335_create_announcement_reactions.rb @@ -0,0 +1,15 @@ +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 diff --git a/db/schema.rb b/db/schema.rb index fc2d3a511..d3a2c05b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -196,15 +196,49 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do 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| @@ -818,6 +852,11 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do 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 diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake index fb9c89aa4..a374e33ad 100644 --- a/lib/tasks/auto_annotate_models.rake +++ b/lib/tasks/auto_annotate_models.rake @@ -4,6 +4,7 @@ if Rails.env.development? task :set_annotation_options do Annotate.set_defaults( 'routes' => 'false', + 'models' => 'true', 'position_in_routes' => 'before', 'position_in_class' => 'before', 'position_in_test' => 'before', diff --git a/spec/controllers/api/v1/announcements/reactions_controller_spec.rb b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb new file mode 100644 index 000000000..72620e242 --- /dev/null +++ b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb @@ -0,0 +1,65 @@ +# 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 diff --git a/spec/controllers/api/v1/announcements_controller_spec.rb b/spec/controllers/api/v1/announcements_controller_spec.rb new file mode 100644 index 000000000..6ee46b60e --- /dev/null +++ b/spec/controllers/api/v1/announcements_controller_spec.rb @@ -0,0 +1,59 @@ +# 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 diff --git a/spec/controllers/api/v1/trends_controller_spec.rb b/spec/controllers/api/v1/trends_controller_spec.rb new file mode 100644 index 000000000..91e0d18fe --- /dev/null +++ b/spec/controllers/api/v1/trends_controller_spec.rb @@ -0,0 +1,18 @@ +# 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 diff --git a/spec/fabricators/announcement_fabricator.rb b/spec/fabricators/announcement_fabricator.rb new file mode 100644 index 000000000..5a3871d90 --- /dev/null +++ b/spec/fabricators/announcement_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:announcement) do + text { Faker::Lorem.paragraph(sentence_count: 2) } + published true + starts_at nil + ends_at nil +end diff --git a/spec/fabricators/announcement_mute_fabricator.rb b/spec/fabricators/announcement_mute_fabricator.rb new file mode 100644 index 000000000..c4eafe8f4 --- /dev/null +++ b/spec/fabricators/announcement_mute_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:announcement_mute) do + account + announcement +end diff --git a/spec/fabricators/announcement_reaction_fabricator.rb b/spec/fabricators/announcement_reaction_fabricator.rb new file mode 100644 index 000000000..f923c59c6 --- /dev/null +++ b/spec/fabricators/announcement_reaction_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:announcement_reaction) do + account + announcement + name '馃尶' +end diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb new file mode 100644 index 000000000..9d0e4c903 --- /dev/null +++ b/spec/models/announcement_mute_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe AnnouncementMute, type: :model do +end diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb new file mode 100644 index 000000000..f6e151584 --- /dev/null +++ b/spec/models/announcement_reaction_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe AnnouncementReaction, type: :model do +end diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb new file mode 100644 index 000000000..7f7b647a9 --- /dev/null +++ b/spec/models/announcement_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe Announcement, type: :model do +end -- cgit From ce1dee85b58fb7ab41af7ea1d276853630ce59a0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 24 Jan 2020 00:20:23 +0100 Subject: Fix relationships page not showing results in admin UI (#12934) Follow-up to #12927 --- app/controllers/admin/relationships_controller.rb | 2 +- app/models/relationship_filter.rb | 20 ++++++++++---------- app/views/admin/accounts/show.html.haml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb index 07d121340..f8a95cfc8 100644 --- a/app/controllers/admin/relationships_controller.rb +++ b/app/controllers/admin/relationships_controller.rb @@ -19,7 +19,7 @@ module Admin end def filter_params - params.slice(RelationshipFilter::KEYS).permit(RelationshipFilter::KEYS) + params.slice(*RelationshipFilter::KEYS).permit(*RelationshipFilter::KEYS) end end end diff --git a/app/models/relationship_filter.rb b/app/models/relationship_filter.rb index fcb3a8dc5..e6859bf3d 100644 --- a/app/models/relationship_filter.rb +++ b/app/models/relationship_filter.rb @@ -20,12 +20,12 @@ class RelationshipFilter end def results - scope = scope_for('relationship', params['relationship']) + scope = scope_for('relationship', params['relationship'].to_s.strip) params.each do |key, value| next if key.to_s == 'page' - scope.merge!(scope_for(key, value)) if value.present? + scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present? end scope @@ -39,7 +39,7 @@ class RelationshipFilter end def scope_for(key, value) - case key.to_s + case key when 'relationship' relationship_scope(value) when 'by_domain' @@ -58,7 +58,7 @@ class RelationshipFilter end def relationship_scope(value) - case value.to_s + case value when 'following' account.following.eager_load(:account_stat).reorder(nil) when 'followed_by' @@ -73,11 +73,11 @@ class RelationshipFilter end def by_domain_scope(value) - Account.where(domain: value.to_s) + Account.where(domain: value) end def location_scope(value) - case value.to_s + case value when 'local' Account.local when 'remote' @@ -88,7 +88,7 @@ class RelationshipFilter end def status_scope(value) - case value.to_s + case value when 'moved' Account.where.not(moved_to_account_id: nil) when 'primary' @@ -99,18 +99,18 @@ class RelationshipFilter end def order_scope(value) - case value.to_s + case value when 'active' Account.by_recent_status when 'recent' - Follow.recent + params[:relationship] == 'invited' ? Account.recent : Follow.recent else raise "Unknown order: #{value}" end end def activity_scope(value) - case value.to_s + case value when 'dormant' AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago))) else diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index f191d8f25..a83f77134 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -41,7 +41,7 @@ .dashboard__counters__num= number_to_human_size @account.media_attachments.sum('file_file_size') .dashboard__counters__label= t 'admin.accounts.media_attachments' %div - = link_to admin_account_relationships_path(@account.id, location: 'local') do + = link_to admin_account_relationships_path(@account.id, location: 'local', relationship: 'followed_by') do .dashboard__counters__num= number_with_delimiter @account.local_followers_count .dashboard__counters__label= t 'admin.accounts.followers' %div -- cgit From daf71573d0e5f1376264c7d32cf55fae284ba9e5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 24 Jan 2020 00:20:38 +0100 Subject: Fix password change/reset not immediately invalidating other sessions (#12928) While making browser requests in the other sessions after a password change or reset does not allow you to be logged in and correctly invalidates the session making the request, sessions have API tokens associated with them, which can still be used until that session is invalidated. This is a security issue for accounts that were already compromised some other way because it makes it harder to throw out the hijacker. --- app/controllers/auth/passwords_controller.rb | 6 ++++++ app/controllers/auth/registrations_controller.rb | 7 +++++++ app/models/user.rb | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) (limited to 'app/controllers') diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 34b98da53..b98bcecd0 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -6,6 +6,12 @@ class Auth::PasswordsController < Devise::PasswordsController layout 'auth' + def update + super do |resource| + resource.session_activations.destroy_all if resource.errors.empty? + end + end + private def check_validity_of_reset_password_token diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 212519c8b..745b91d46 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -22,10 +22,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController not_found end + def update + super do |resource| + resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password? + end + end + protected def update_resource(resource, params) params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank? + super end diff --git a/app/models/user.rb b/app/models/user.rb index a43e63b2e..058a8d5f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -247,7 +247,7 @@ class User < ApplicationRecord ip: request.remote_ip).session_id end - def exclusive_session(id) + def clear_other_sessions(id) session_activations.exclusive(id) end -- cgit From c4c315ea40356b9b598a10b49ea9455deace4553 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 24 Jan 2020 00:20:51 +0100 Subject: Fix OEmbed leaking information about existence of non-public statuses (#12930) --- app/controllers/api/oembed_controller.rb | 14 +++++++++++--- app/controllers/statuses_controller.rb | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) (limited to 'app/controllers') diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index c8c60b1cf..66da65bed 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -1,17 +1,25 @@ # frozen_string_literal: true class Api::OEmbedController < Api::BaseController - respond_to :json - skip_before_action :require_authenticated_user! + before_action :set_status + before_action :require_public_status! + def show - @status = status_finder.status render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default end private + def set_status + @status = status_finder.status + end + + def require_public_status! + not_found if @status.hidden? + end + def status_finder StatusFinder.new(params[:url]) end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 57bbeca64..4fa128303 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -46,7 +46,7 @@ class StatusesController < ApplicationController end def embed - raise ActiveRecord::RecordNotFound if @status.hidden? + return not_found if @status.hidden? expires_in 180, public: true response.headers['X-Frame-Options'] = 'ALLOWALL' @@ -68,7 +68,7 @@ class StatusesController < ApplicationController @status = @account.statuses.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def set_instance_presenter -- cgit