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
This commit is contained in:
parent
81cc86bb1f
commit
f52c988e12
69
app/controllers/admin/announcements_controller.rb
Normal file
69
app/controllers/admin/announcements_controller.rb
Normal file
|
@ -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
|
|
@ -85,7 +85,7 @@ class Api::BaseController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_authenticated_user!
|
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
|
end
|
||||||
|
|
||||||
def require_user!
|
def require_user!
|
||||||
|
|
29
app/controllers/api/v1/announcements/reactions_controller.rb
Normal file
29
app/controllers/api/v1/announcements/reactions_controller.rb
Normal file
|
@ -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
|
33
app/controllers/api/v1/announcements_controller.rb
Normal file
33
app/controllers/api/v1/announcements_controller.rb
Normal file
|
@ -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
|
|
@ -22,6 +22,8 @@ module Admin::ActionLogsHelper
|
||||||
log.recorded_changes.slice('severity', 'reject_media')
|
log.recorded_changes.slice('severity', 'reject_media')
|
||||||
elsif log.target_type == 'Status' && log.action == :update
|
elsif log.target_type == 'Status' && log.action == :update
|
||||||
log.recorded_changes.slice('sensitive')
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -52,6 +54,8 @@ module Admin::ActionLogsHelper
|
||||||
'pencil'
|
'pencil'
|
||||||
when 'AccountWarning'
|
when 'AccountWarning'
|
||||||
'warning'
|
'warning'
|
||||||
|
when 'Announcement'
|
||||||
|
'bullhorn'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -94,6 +98,8 @@ module Admin::ActionLogsHelper
|
||||||
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
|
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
|
||||||
when 'AccountWarning'
|
when 'AccountWarning'
|
||||||
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -111,6 +117,8 @@ module Admin::ActionLogsHelper
|
||||||
else
|
else
|
||||||
I18n.t('admin.action_logs.deleted_status')
|
I18n.t('admin.action_logs.deleted_status')
|
||||||
end
|
end
|
||||||
|
when 'Announcement'
|
||||||
|
"##{attributes['id']}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
11
app/helpers/admin/announcements_helper.rb
Normal file
11
app/helpers/admin/announcements_helper.rb
Normal file
|
@ -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
|
|
@ -9,6 +9,7 @@ module Admin::FilterHelper
|
||||||
InstanceFilter::KEYS,
|
InstanceFilter::KEYS,
|
||||||
InviteFilter::KEYS,
|
InviteFilter::KEYS,
|
||||||
RelationshipFilter::KEYS,
|
RelationshipFilter::KEYS,
|
||||||
|
AnnouncementFilter::KEYS,
|
||||||
].flatten.freeze
|
].flatten.freeze
|
||||||
|
|
||||||
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
|
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
|
||||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
133
app/javascript/mastodon/actions/announcements.js
Normal file
133
app/javascript/mastodon/actions/announcements.js
Normal file
|
@ -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,
|
||||||
|
});
|
|
@ -76,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
|
|
||||||
export function normalizePoll(poll) {
|
export function normalizePoll(poll) {
|
||||||
const normalPoll = { ...poll };
|
const normalPoll = { ...poll };
|
||||||
|
|
||||||
const emojiMap = makeEmojiMap(normalPoll);
|
const emojiMap = makeEmojiMap(normalPoll);
|
||||||
|
|
||||||
normalPoll.options = poll.options.map((option, index) => ({
|
normalPoll.options = poll.options.map((option, index) => ({
|
||||||
|
@ -87,3 +86,12 @@ export function normalizePoll(poll) {
|
||||||
|
|
||||||
return normalPoll;
|
return normalPoll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeAnnouncement(announcement) {
|
||||||
|
const normalAnnouncement = { ...announcement };
|
||||||
|
const emojiMap = makeEmojiMap(normalAnnouncement);
|
||||||
|
|
||||||
|
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||||
|
|
||||||
|
return normalAnnouncement;
|
||||||
|
}
|
||||||
|
|
|
@ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
||||||
|
|
||||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||||
fetchRelatedRelationships(dispatch, response.data);
|
fetchRelatedRelationships(dispatch, response.data);
|
||||||
done();
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||||
|
}).finally(() => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
|
||||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||||
error,
|
error,
|
||||||
skipLoading: !isLoadingMore,
|
skipLoading: !isLoadingMore,
|
||||||
|
skipAlert: !isLoadingMore,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from './timelines';
|
} from './timelines';
|
||||||
import { updateNotifications, expandNotifications } from './notifications';
|
import { updateNotifications, expandNotifications } from './notifications';
|
||||||
import { updateConversations } from './conversations';
|
import { updateConversations } from './conversations';
|
||||||
|
import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
|
||||||
import { fetchFilters } from './filters';
|
import { fetchFilters } from './filters';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
|
|
||||||
|
@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||||
case 'filters_changed':
|
case 'filters_changed':
|
||||||
dispatch(fetchFilters());
|
dispatch(fetchFilters());
|
||||||
break;
|
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) => {
|
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||||
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
|
dispatch(expandHomeTimeline({}, () =>
|
||||||
|
dispatch(expandNotifications({}, () =>
|
||||||
|
dispatch(fetchAnnouncements(done))))));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||||
|
|
|
@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
dispatch(importFetchedStatuses(response.data));
|
||||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||||
done();
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||||
|
}).finally(() => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
|
||||||
<div>
|
<div>
|
||||||
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
|
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
|
||||||
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
|
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
|
||||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||||
onPickEmoji: PropTypes.func.isRequired,
|
onPickEmoji: PropTypes.func.isRequired,
|
||||||
onSkinTone: PropTypes.func.isRequired,
|
onSkinTone: PropTypes.func.isRequired,
|
||||||
skinTone: PropTypes.number.isRequired,
|
skinTone: PropTypes.number.isRequired,
|
||||||
|
button: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
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 title = intl.formatMessage(messages.emoji);
|
||||||
const { active, loading, placement } = this.state;
|
const { active, loading, placement } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||||
<img
|
{button || <img
|
||||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||||
alt='🙂'
|
alt='🙂'
|
||||||
src={`${assetHost}/emoji/1f602.svg`}
|
src={`${assetHost}/emoji/1f602.svg`}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className='announcements__item__content'
|
||||||
|
ref={this.setRef}
|
||||||
|
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetHost = process.env.CDN_HOST || '';
|
||||||
|
|
||||||
|
class Emoji extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
emoji: PropTypes.string.isRequired,
|
||||||
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
|
hovered: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { emoji, emojiMap, hovered } = this.props;
|
||||||
|
|
||||||
|
if (unicodeMapping[emoji]) {
|
||||||
|
const { filename, shortCode } = unicodeMapping[this.props.emoji];
|
||||||
|
const title = shortCode ? `:${shortCode}:` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
draggable='false'
|
||||||
|
className='emojione'
|
||||||
|
alt={emoji}
|
||||||
|
title={title}
|
||||||
|
src={`${assetHost}/emoji/${filename}.svg`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (emojiMap.get(emoji)) {
|
||||||
|
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
|
||||||
|
const shortCode = `:${emoji}:`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
draggable='false'
|
||||||
|
className='emojione custom-emoji'
|
||||||
|
alt={shortCode}
|
||||||
|
title={shortCode}
|
||||||
|
src={filename}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reaction extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
announcementId: PropTypes.string.isRequired,
|
||||||
|
reaction: ImmutablePropTypes.map.isRequired,
|
||||||
|
addReaction: PropTypes.func.isRequired,
|
||||||
|
removeReaction: PropTypes.func.isRequired,
|
||||||
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
hovered: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
const { reaction, announcementId, addReaction, removeReaction } = this.props;
|
||||||
|
|
||||||
|
if (reaction.get('me')) {
|
||||||
|
removeReaction(announcementId, reaction.get('name'));
|
||||||
|
} else {
|
||||||
|
addReaction(announcementId, reaction.get('name'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseEnter = () => this.setState({ hovered: true })
|
||||||
|
|
||||||
|
handleMouseLeave = () => this.setState({ hovered: false })
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { reaction } = this.props;
|
||||||
|
|
||||||
|
let shortCode = reaction.get('name');
|
||||||
|
|
||||||
|
if (unicodeMapping[shortCode]) {
|
||||||
|
shortCode = unicodeMapping[shortCode].shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
|
||||||
|
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||||
|
<span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactionsBar extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
announcementId: PropTypes.string.isRequired,
|
||||||
|
reactions: ImmutablePropTypes.list.isRequired,
|
||||||
|
addReaction: PropTypes.func.isRequired,
|
||||||
|
removeReaction: PropTypes.func.isRequired,
|
||||||
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEmojiPick = data => {
|
||||||
|
const { addReaction, announcementId } = this.props;
|
||||||
|
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { reactions } = this.props;
|
||||||
|
const visibleReactions = reactions.filter(x => x.get('count') > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||||
|
{visibleReactions.map(reaction => (
|
||||||
|
<Reaction
|
||||||
|
key={reaction.get('name')}
|
||||||
|
reaction={reaction}
|
||||||
|
announcementId={this.props.announcementId}
|
||||||
|
addReaction={this.props.addReaction}
|
||||||
|
removeReaction={this.props.removeReaction}
|
||||||
|
emojiMap={this.props.emojiMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class Announcement extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
announcement: ImmutablePropTypes.map.isRequired,
|
||||||
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
|
dismissAnnouncement: PropTypes.func.isRequired,
|
||||||
|
addReaction: PropTypes.func.isRequired,
|
||||||
|
removeReaction: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDismissClick = () => {
|
||||||
|
const { dismissAnnouncement, announcement } = this.props;
|
||||||
|
dismissAnnouncement(announcement.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { announcement, intl } = this.props;
|
||||||
|
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
|
||||||
|
const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
|
||||||
|
const now = new Date();
|
||||||
|
const hasTimeRange = startsAt && endsAt;
|
||||||
|
const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
|
||||||
|
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
|
||||||
|
const skipTime = announcement.get('all_day');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='announcements__item'>
|
||||||
|
<strong className='announcements__item__range'>
|
||||||
|
<FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
|
||||||
|
{hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<Content announcement={announcement} />
|
||||||
|
|
||||||
|
<ReactionsBar
|
||||||
|
reactions={announcement.get('reactions')}
|
||||||
|
announcementId={announcement.get('id')}
|
||||||
|
addReaction={this.props.addReaction}
|
||||||
|
removeReaction={this.props.removeReaction}
|
||||||
|
emojiMap={this.props.emojiMap}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class Announcements extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
announcements: ImmutablePropTypes.list,
|
||||||
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
|
fetchAnnouncements: PropTypes.func.isRequired,
|
||||||
|
dismissAnnouncement: PropTypes.func.isRequired,
|
||||||
|
addReaction: PropTypes.func.isRequired,
|
||||||
|
removeReaction: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
index: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { fetchAnnouncements } = this.props;
|
||||||
|
fetchAnnouncements();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChangeIndex = index => {
|
||||||
|
this.setState({ index: index % this.props.announcements.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNextClick = () => {
|
||||||
|
this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePrevClick = () => {
|
||||||
|
this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { announcements, intl } = this.props;
|
||||||
|
const { index } = this.state;
|
||||||
|
|
||||||
|
if (announcements.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='announcements'>
|
||||||
|
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||||
|
|
||||||
|
<div className='announcements__container'>
|
||||||
|
<ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
|
||||||
|
{announcements.map(announcement => (
|
||||||
|
<Announcement
|
||||||
|
key={announcement.get('id')}
|
||||||
|
announcement={announcement}
|
||||||
|
emojiMap={this.props.emojiMap}
|
||||||
|
dismissAnnouncement={this.props.dismissAnnouncement}
|
||||||
|
addReaction={this.props.addReaction}
|
||||||
|
removeReaction={this.props.removeReaction}
|
||||||
|
intl={intl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
|
||||||
|
<div className='announcements__pagination'>
|
||||||
|
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
|
||||||
|
<span>{index + 1} / {announcements.size}</span>
|
||||||
|
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
|
@ -1,5 +1,5 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { fetchTrends } from '../../../actions/trends';
|
import { fetchTrends } from 'mastodon/actions/trends';
|
||||||
import Trends from '../components/trends';
|
import Trends from '../components/trends';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
|
@ -113,6 +114,8 @@ class HomeTimeline extends React.PureComponent {
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={<AnnouncementsContainer />}
|
||||||
|
alwaysPrepend
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`home_timeline-${columnId}`}
|
scrollKey={`home_timeline-${columnId}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
|
|
|
@ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
style={swipeableViewsStyle}
|
style={swipeableViewsStyle}
|
||||||
containerStyle={containerStyle}
|
containerStyle={containerStyle}
|
||||||
onChangeIndex={this.handleSwipe}
|
onChangeIndex={this.handleSwipe}
|
||||||
onSwitching={this.handleSwitching}
|
|
||||||
index={index}
|
index={index}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|
72
app/javascript/mastodon/reducers/announcements.js
Normal file
72
app/javascript/mastodon/reducers/announcements.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -34,8 +34,10 @@ import polls from './polls';
|
||||||
import identity_proofs from './identity_proofs';
|
import identity_proofs from './identity_proofs';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
import missed_updates from './missed_updates';
|
import missed_updates from './missed_updates';
|
||||||
|
import announcements from './announcements';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
|
announcements,
|
||||||
dropdown_menu,
|
dropdown_menu,
|
||||||
timelines,
|
timelines,
|
||||||
meta,
|
meta,
|
||||||
|
|
|
@ -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 {
|
.status__content.status__content--collapsed {
|
||||||
max-height: 20px * 15; // 15 lines is roughly above 500 characters
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -222,6 +222,12 @@ code {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input.datetime .label_input select {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.required abbr {
|
.required abbr {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: lighten($error-value-color, 12%);
|
color: lighten($error-value-color, 12%);
|
||||||
|
|
|
@ -8,7 +8,7 @@ class EntityCache
|
||||||
MAX_EXPIRATION = 7.days.freeze
|
MAX_EXPIRATION = 7.days.freeze
|
||||||
|
|
||||||
def mention(username, domain)
|
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
|
end
|
||||||
|
|
||||||
def emoji(shortcodes, domain)
|
def emoji(shortcodes, domain)
|
||||||
|
|
|
@ -15,6 +15,10 @@ class InlineRenderer
|
||||||
serializer = REST::NotificationSerializer
|
serializer = REST::NotificationSerializer
|
||||||
when :conversation
|
when :conversation
|
||||||
serializer = REST::ConversationSerializer
|
serializer = REST::ConversationSerializer
|
||||||
|
when :announcement
|
||||||
|
serializer = REST::AnnouncementSerializer
|
||||||
|
when :reaction
|
||||||
|
serializer = REST::ReactionSerializer
|
||||||
else
|
else
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
|
@ -476,6 +476,12 @@ class Account < ApplicationRecord
|
||||||
records
|
records
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def generate_query_for_search(terms)
|
def generate_query_for_search(terms)
|
||||||
|
|
85
app/models/announcement.rb
Normal file
85
app/models/announcement.rb
Normal file
|
@ -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
|
39
app/models/announcement_filter.rb
Normal file
39
app/models/announcement_filter.rb
Normal file
|
@ -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
|
19
app/models/announcement_mute.rb
Normal file
19
app/models/announcement_mute.rb
Normal file
|
@ -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
|
37
app/models/announcement_reaction.rb
Normal file
37
app/models/announcement_reaction.rb
Normal file
|
@ -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
|
|
@ -7,11 +7,11 @@
|
||||||
# user_id :bigint(8)
|
# user_id :bigint(8)
|
||||||
# dump_file_name :string
|
# dump_file_name :string
|
||||||
# dump_content_type :string
|
# dump_content_type :string
|
||||||
# dump_file_size :bigint
|
|
||||||
# dump_updated_at :datetime
|
# dump_updated_at :datetime
|
||||||
# processed :boolean default(FALSE), not null
|
# processed :boolean default(FALSE), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
# dump_file_size :bigint(8)
|
||||||
#
|
#
|
||||||
|
|
||||||
class Backup < ApplicationRecord
|
class Backup < ApplicationRecord
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
#
|
#
|
||||||
# Table name: bookmarks
|
# 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
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# account_id :integer not null
|
|
||||||
# status_id :integer not null
|
|
||||||
#
|
#
|
||||||
|
|
||||||
class Bookmark < ApplicationRecord
|
class Bookmark < ApplicationRecord
|
||||||
|
|
|
@ -84,6 +84,7 @@ module AccountInteractions
|
||||||
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
|
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
|
||||||
has_many :conversation_mutes, dependent: :destroy
|
has_many :conversation_mutes, dependent: :destroy
|
||||||
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
|
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
|
||||||
|
has_many :announcement_mutes, dependent: :destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow!(other_account, reblogs: nil, uri: nil)
|
def follow!(other_account, reblogs: nil, uri: nil)
|
||||||
|
|
|
@ -67,7 +67,7 @@ class CustomEmoji < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def from_text(text, domain)
|
def from_text(text, domain = nil)
|
||||||
return [] if text.blank?
|
return [] if text.blank?
|
||||||
|
|
||||||
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
|
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
|
||||||
|
|
19
app/policies/announcement_policy.rb
Normal file
19
app/policies/announcement_policy.rb
Normal file
|
@ -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
|
34
app/serializers/rest/announcement_serializer.rb
Normal file
34
app/serializers/rest/announcement_serializer.rb
Normal file
|
@ -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
|
31
app/serializers/rest/reaction_serializer.rb
Normal file
31
app/serializers/rest/reaction_serializer.rb
Normal file
|
@ -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
|
17
app/validators/reaction_validator.rb
Normal file
17
app/validators/reaction_validator.rb
Normal file
|
@ -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
|
14
app/views/admin/announcements/_announcement.html.haml
Normal file
14
app/views/admin/announcements/_announcement.html.haml
Normal file
|
@ -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)
|
22
app/views/admin/announcements/edit.html.haml
Normal file
22
app/views/admin/announcements/edit.html.haml
Normal file
|
@ -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
|
30
app/views/admin/announcements/index.html.haml
Normal file
30
app/views/admin/announcements/index.html.haml
Normal file
|
@ -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
|
||||||
|
|
21
app/views/admin/announcements/new.html.haml
Normal file
21
app/views/admin/announcements/new.html.haml
Normal file
|
@ -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
|
22
app/workers/publish_announcement_reaction_worker.rb
Normal file
22
app/workers/publish_announcement_reaction_worker.rb
Normal file
|
@ -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
|
18
app/workers/publish_scheduled_announcement_worker.rb
Normal file
18
app/workers/publish_scheduled_announcement_worker.rb
Normal file
|
@ -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
|
|
@ -6,14 +6,38 @@ class Scheduler::ScheduledStatusesScheduler
|
||||||
sidekiq_options unique: :until_executed, retry: 0
|
sidekiq_options unique: :until_executed, retry: 0
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
publish_scheduled_statuses!
|
||||||
|
publish_scheduled_announcements!
|
||||||
|
unpublish_expired_announcements!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def publish_scheduled_statuses!
|
||||||
due_statuses.find_each do |scheduled_status|
|
due_statuses.find_each do |scheduled_status|
|
||||||
PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at, scheduled_status.id)
|
PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at, scheduled_status.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def due_statuses
|
def due_statuses
|
||||||
ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
|
ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -98,7 +98,7 @@ SimpleForm.setup do |config|
|
||||||
b.use :html5
|
b.use :html5
|
||||||
b.use :label
|
b.use :label
|
||||||
b.use :hint, wrap_with: { tag: :span, class: :hint }
|
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 }
|
b.use :error, wrap_with: { tag: :span, class: :error }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -198,11 +198,13 @@ en:
|
||||||
change_email_user: "%{name} changed the e-mail address of user %{target}"
|
change_email_user: "%{name} changed the e-mail address of user %{target}"
|
||||||
confirm_user: "%{name} confirmed 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_account_warning: "%{name} sent a warning to %{target}"
|
||||||
|
create_announcement: "%{name} created new announcement %{target}"
|
||||||
create_custom_emoji: "%{name} uploaded new emoji %{target}"
|
create_custom_emoji: "%{name} uploaded new emoji %{target}"
|
||||||
create_domain_allow: "%{name} whitelisted domain %{target}"
|
create_domain_allow: "%{name} whitelisted domain %{target}"
|
||||||
create_domain_block: "%{name} blocked domain %{target}"
|
create_domain_block: "%{name} blocked domain %{target}"
|
||||||
create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
|
create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
|
||||||
demote_user: "%{name} demoted user %{target}"
|
demote_user: "%{name} demoted user %{target}"
|
||||||
|
destroy_announcement: "%{name} deleted announcement %{target}"
|
||||||
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
|
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
|
||||||
destroy_domain_allow: "%{name} removed domain %{target} from whitelist"
|
destroy_domain_allow: "%{name} removed domain %{target} from whitelist"
|
||||||
destroy_domain_block: "%{name} unblocked domain %{target}"
|
destroy_domain_block: "%{name} unblocked domain %{target}"
|
||||||
|
@ -224,10 +226,22 @@ en:
|
||||||
unassigned_report: "%{name} unassigned report %{target}"
|
unassigned_report: "%{name} unassigned report %{target}"
|
||||||
unsilence_account: "%{name} unsilenced %{target}'s account"
|
unsilence_account: "%{name} unsilenced %{target}'s account"
|
||||||
unsuspend_account: "%{name} unsuspended %{target}'s account"
|
unsuspend_account: "%{name} unsuspended %{target}'s account"
|
||||||
|
update_announcement: "%{name} updated announcement %{target}"
|
||||||
update_custom_emoji: "%{name} updated emoji %{target}"
|
update_custom_emoji: "%{name} updated emoji %{target}"
|
||||||
update_status: "%{name} updated status by %{target}"
|
update_status: "%{name} updated status by %{target}"
|
||||||
deleted_status: "(deleted status)"
|
deleted_status: "(deleted status)"
|
||||||
title: Audit log
|
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:
|
custom_emojis:
|
||||||
assign_category: Assign category
|
assign_category: Assign category
|
||||||
by_domain: Domain
|
by_domain: Domain
|
||||||
|
@ -657,6 +671,9 @@ en:
|
||||||
hint_html: "<strong>Tip:</strong> We won't ask you for your password again for the next hour."
|
hint_html: "<strong>Tip:</strong> We won't ask you for your password again for the next hour."
|
||||||
invalid_password: Invalid password
|
invalid_password: Invalid password
|
||||||
prompt: Confirm password to continue
|
prompt: Confirm password to continue
|
||||||
|
date:
|
||||||
|
formats:
|
||||||
|
default: "%b %d, %Y"
|
||||||
datetime:
|
datetime:
|
||||||
distance_in_words:
|
distance_in_words:
|
||||||
about_x_hours: "%{count}h"
|
about_x_hours: "%{count}h"
|
||||||
|
@ -758,6 +775,8 @@ en:
|
||||||
all: All
|
all: All
|
||||||
changes_saved_msg: Changes successfully saved!
|
changes_saved_msg: Changes successfully saved!
|
||||||
copy: Copy
|
copy: Copy
|
||||||
|
delete: Delete
|
||||||
|
edit: Edit
|
||||||
no_batch_actions_available: No batch actions available on this page
|
no_batch_actions_available: No batch actions available on this page
|
||||||
order_by: Order by
|
order_by: Order by
|
||||||
save_changes: Save changes
|
save_changes: Save changes
|
||||||
|
@ -930,6 +949,9 @@ en:
|
||||||
other: Other
|
other: Other
|
||||||
posting_defaults: Posting defaults
|
posting_defaults: Posting defaults
|
||||||
public_timelines: Public timelines
|
public_timelines: Public timelines
|
||||||
|
reactions:
|
||||||
|
errors:
|
||||||
|
unrecognized_emoji: is not a recognized emoji
|
||||||
relationships:
|
relationships:
|
||||||
activity: Account activity
|
activity: Account activity
|
||||||
dormant: Dormant
|
dormant: Dormant
|
||||||
|
|
|
@ -14,6 +14,12 @@ en:
|
||||||
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
|
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
|
||||||
type_html: Choose what to do with <strong>%{acct}</strong>
|
type_html: Choose what to do with <strong>%{acct}</strong>
|
||||||
warning_preset_id: Optional. You can still add custom text to end of the preset
|
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:
|
defaults:
|
||||||
autofollow: People who sign up through the invite will automatically follow you
|
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
|
avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
|
||||||
|
@ -83,6 +89,12 @@ en:
|
||||||
silence: Silence
|
silence: Silence
|
||||||
suspend: Suspend and irreversibly delete account data
|
suspend: Suspend and irreversibly delete account data
|
||||||
warning_preset_id: Use a warning preset
|
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:
|
defaults:
|
||||||
autofollow: Invite to follow your account
|
autofollow: Invite to follow your account
|
||||||
avatar: Avatar
|
avatar: Avatar
|
||||||
|
|
|
@ -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|
|
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 :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 :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 :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 :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? }
|
s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
|
||||||
|
|
|
@ -173,9 +173,12 @@ Rails.application.routes.draw do
|
||||||
get :edit
|
get :edit
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
|
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
|
||||||
resources :action_logs, only: [:index]
|
resources :action_logs, only: [:index]
|
||||||
resources :warning_presets, except: [:new]
|
resources :warning_presets, except: [:new]
|
||||||
|
resources :announcements, except: [:show]
|
||||||
|
|
||||||
resource :settings, only: [:edit, :update]
|
resource :settings, only: [:edit, :update]
|
||||||
|
|
||||||
resources :invites, only: [:index, :create, :destroy] do
|
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 :scheduled_statuses, only: [:index, :show, :update, :destroy]
|
||||||
resources :preferences, only: [:index]
|
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
|
resources :conversations, only: [:index, :destroy] do
|
||||||
member do
|
member do
|
||||||
post :read
|
post :read
|
||||||
|
|
16
db/migrate/20191218153258_create_announcements.rb
Normal file
16
db/migrate/20191218153258_create_announcements.rb
Normal file
|
@ -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
|
12
db/migrate/20200113125135_create_announcement_mutes.rb
Normal file
12
db/migrate/20200113125135_create_announcement_mutes.rb
Normal file
|
@ -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
|
15
db/migrate/20200114113335_create_announcement_reactions.rb
Normal file
15
db/migrate/20200114113335_create_announcement_reactions.rb
Normal file
|
@ -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
|
41
db/schema.rb
41
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"
|
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
|
||||||
end
|
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|
|
create_table "backups", force: :cascade do |t|
|
||||||
t.bigint "user_id"
|
t.bigint "user_id"
|
||||||
t.string "dump_file_name"
|
t.string "dump_file_name"
|
||||||
t.string "dump_content_type"
|
t.string "dump_content_type"
|
||||||
t.bigint "dump_file_size"
|
|
||||||
t.datetime "dump_updated_at"
|
t.datetime "dump_updated_at"
|
||||||
t.boolean "processed", default: false, null: false
|
t.boolean "processed", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "dump_file_size"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "blocks", force: :cascade do |t|
|
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 "account_warnings", "accounts", on_delete: :nullify
|
||||||
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", 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 "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 "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", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
|
||||||
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
|
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
|
||||||
|
|
|
@ -4,6 +4,7 @@ if Rails.env.development?
|
||||||
task :set_annotation_options do
|
task :set_annotation_options do
|
||||||
Annotate.set_defaults(
|
Annotate.set_defaults(
|
||||||
'routes' => 'false',
|
'routes' => 'false',
|
||||||
|
'models' => 'true',
|
||||||
'position_in_routes' => 'before',
|
'position_in_routes' => 'before',
|
||||||
'position_in_class' => 'before',
|
'position_in_class' => 'before',
|
||||||
'position_in_test' => 'before',
|
'position_in_test' => 'before',
|
||||||
|
|
|
@ -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
|
59
spec/controllers/api/v1/announcements_controller_spec.rb
Normal file
59
spec/controllers/api/v1/announcements_controller_spec.rb
Normal file
|
@ -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
|
18
spec/controllers/api/v1/trends_controller_spec.rb
Normal file
18
spec/controllers/api/v1/trends_controller_spec.rb
Normal file
|
@ -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
|
6
spec/fabricators/announcement_fabricator.rb
Normal file
6
spec/fabricators/announcement_fabricator.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Fabricator(:announcement) do
|
||||||
|
text { Faker::Lorem.paragraph(sentence_count: 2) }
|
||||||
|
published true
|
||||||
|
starts_at nil
|
||||||
|
ends_at nil
|
||||||
|
end
|
4
spec/fabricators/announcement_mute_fabricator.rb
Normal file
4
spec/fabricators/announcement_mute_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
Fabricator(:announcement_mute) do
|
||||||
|
account
|
||||||
|
announcement
|
||||||
|
end
|
5
spec/fabricators/announcement_reaction_fabricator.rb
Normal file
5
spec/fabricators/announcement_reaction_fabricator.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Fabricator(:announcement_reaction) do
|
||||||
|
account
|
||||||
|
announcement
|
||||||
|
name '🌿'
|
||||||
|
end
|
4
spec/models/announcement_mute_spec.rb
Normal file
4
spec/models/announcement_mute_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AnnouncementMute, type: :model do
|
||||||
|
end
|
4
spec/models/announcement_reaction_spec.rb
Normal file
4
spec/models/announcement_reaction_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AnnouncementReaction, type: :model do
|
||||||
|
end
|
4
spec/models/announcement_spec.rb
Normal file
4
spec/models/announcement_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Announcement, type: :model do
|
||||||
|
end
|
Loading…
Reference in a new issue