mirror of
https://framagit.org/tykayn/mastodon.git
synced 2023-08-25 08:33:12 +02:00
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
|
||||
|
||||
def require_authenticated_user!
|
||||
render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
|
||||
render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
|
||||
end
|
||||
|
||||
def require_user!
|
||||
|
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')
|
||||
elsif log.target_type == 'Status' && log.action == :update
|
||||
log.recorded_changes.slice('sensitive')
|
||||
elsif log.target_type == 'Announcement' && log.action == :update
|
||||
log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
|
||||
end
|
||||
end
|
||||
|
||||
@ -52,6 +54,8 @@ module Admin::ActionLogsHelper
|
||||
'pencil'
|
||||
when 'AccountWarning'
|
||||
'warning'
|
||||
when 'Announcement'
|
||||
'bullhorn'
|
||||
end
|
||||
end
|
||||
|
||||
@ -94,6 +98,8 @@ module Admin::ActionLogsHelper
|
||||
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
|
||||
when 'AccountWarning'
|
||||
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
||||
when 'Announcement'
|
||||
link_to "##{record.id}", edit_admin_announcement_path(record.id)
|
||||
end
|
||||
end
|
||||
|
||||
@ -111,6 +117,8 @@ module Admin::ActionLogsHelper
|
||||
else
|
||||
I18n.t('admin.action_logs.deleted_status')
|
||||
end
|
||||
when 'Announcement'
|
||||
"##{attributes['id']}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
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,
|
||||
InviteFilter::KEYS,
|
||||
RelationshipFilter::KEYS,
|
||||
AnnouncementFilter::KEYS,
|
||||
].flatten.freeze
|
||||
|
||||
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) {
|
||||
const normalPoll = { ...poll };
|
||||
|
||||
const emojiMap = makeEmojiMap(normalPoll);
|
||||
|
||||
normalPoll.options = poll.options.map((option, index) => ({
|
||||
@ -87,3 +86,12 @@ export function normalizePoll(poll) {
|
||||
|
||||
return normalPoll;
|
||||
}
|
||||
|
||||
export function normalizeAnnouncement(announcement) {
|
||||
const normalAnnouncement = { ...announcement };
|
||||
const emojiMap = makeEmojiMap(normalAnnouncement);
|
||||
|
||||
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||
|
||||
return normalAnnouncement;
|
||||
}
|
||||
|
@ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
};
|
||||
@ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
|
||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||
error,
|
||||
skipLoading: !isLoadingMore,
|
||||
skipAlert: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
} from './timelines';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateConversations } from './conversations';
|
||||
import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
|
||||
import { fetchFilters } from './filters';
|
||||
import { getLocale } from '../locales';
|
||||
|
||||
@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||
case 'filters_changed':
|
||||
dispatch(fetchFilters());
|
||||
break;
|
||||
case 'announcement':
|
||||
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'announcement.reaction':
|
||||
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||
}
|
||||
|
||||
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
|
||||
dispatch(expandHomeTimeline({}, () =>
|
||||
dispatch(expandNotifications({}, () =>
|
||||
dispatch(fetchAnnouncements(done))))));
|
||||
};
|
||||
|
||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||
|
@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
|
||||
<div>
|
||||
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
|
||||
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||
<img
|
||||
{button || <img
|
||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||
alt='🙂'
|
||||
src={`${assetHost}/emoji/1f602.svg`}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||
|
@ -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 { fetchTrends } from '../../../actions/trends';
|
||||
import { fetchTrends } from 'mastodon/actions/trends';
|
||||
import Trends from '../components/trends';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
@ -113,6 +114,8 @@ class HomeTimeline extends React.PureComponent {
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
prepend={<AnnouncementsContainer />}
|
||||
alwaysPrepend
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`home_timeline-${columnId}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
@ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent {
|
||||
style={swipeableViewsStyle}
|
||||
containerStyle={containerStyle}
|
||||
onChangeIndex={this.handleSwipe}
|
||||
onSwitching={this.handleSwitching}
|
||||
index={index}
|
||||
>
|
||||
{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 trends from './trends';
|
||||
import missed_updates from './missed_updates';
|
||||
import announcements from './announcements';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
dropdown_menu,
|
||||
timelines,
|
||||
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 {
|
||||
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 {
|
||||
text-decoration: none;
|
||||
color: lighten($error-value-color, 12%);
|
||||
|
@ -8,7 +8,7 @@ class EntityCache
|
||||
MAX_EXPIRATION = 7.days.freeze
|
||||
|
||||
def mention(username, domain)
|
||||
Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
|
||||
Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:id, :username, :domain, :url).find_remote(username, domain) }
|
||||
end
|
||||
|
||||
def emoji(shortcodes, domain)
|
||||
|
@ -15,6 +15,10 @@ class InlineRenderer
|
||||
serializer = REST::NotificationSerializer
|
||||
when :conversation
|
||||
serializer = REST::ConversationSerializer
|
||||
when :announcement
|
||||
serializer = REST::AnnouncementSerializer
|
||||
when :reaction
|
||||
serializer = REST::ReactionSerializer
|
||||
else
|
||||
return
|
||||
end
|
||||
|
@ -476,6 +476,12 @@ class Account < ApplicationRecord
|
||||
records
|
||||
end
|
||||
|
||||
def from_text(text)
|
||||
return [] if text.blank?
|
||||
|
||||
text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.map { |(username, domain)| EntityCache.instance.mention(username, domain) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_query_for_search(terms)
|
||||
|