Add reaction deck

This commit is contained in:
KMY 2023-05-26 15:15:48 +09:00
parent db5c358f4f
commit f1625fe101
24 changed files with 404 additions and 13 deletions

View file

@ -0,0 +1,79 @@
import api from '../api';
export const REACTION_DECK_FETCH_REQUEST = 'REACTION_DECK_FETCH_REQUEST';
export const REACTION_DECK_FETCH_SUCCESS = 'REACTION_DECK_FETCH_SUCCESS';
export const REACTION_DECK_FETCH_FAIL = 'REACTION_DECK_FETCH_FAIL';
export const REACTION_DECK_UPDATE_REQUEST = 'REACTION_DECK_UPDATE_REQUEST';
export const REACTION_DECK_UPDATE_SUCCESS = 'REACTION_DECK_UPDATE_SUCCESS';
export const REACTION_DECK_UPDATE_FAIL = 'REACTION_DECK_UPDATE_FAIL';
export function fetchReactionDeck() {
return (dispatch, getState) => {
dispatch(fetchReactionDeckRequest());
api(getState).get('/api/v1/reaction_deck').then(response => {
dispatch(fetchReactionDeckSuccess(response.data));
}).catch(error => {
dispatch(fetchReactionDeckFail(error));
});
};
}
export function fetchReactionDeckRequest() {
return {
type: REACTION_DECK_FETCH_REQUEST,
skipLoading: true,
};
}
export function fetchReactionDeckSuccess(emojis) {
return {
type: REACTION_DECK_FETCH_SUCCESS,
emojis,
skipLoading: true,
};
}
export function fetchReactionDeckFail(error) {
return {
type: REACTION_DECK_FETCH_FAIL,
error,
skipLoading: true,
};
}
export function updateReactionDeck(id, emoji) {
return (dispatch, getState) => {
dispatch(updateReactionDeckRequest());
api(getState).post('/api/v1/reaction_deck', { emojis: [{ id, emoji: emoji.native || emoji.id }] }).then(response => {
dispatch(updateReactionDeckSuccess(response.data));
}).catch(error => {
dispatch(updateReactionDeckFail(error));
});
};
}
export function updateReactionDeckRequest() {
return {
type: REACTION_DECK_UPDATE_REQUEST,
skipLoading: true,
};
}
export function updateReactionDeckSuccess(emojis) {
return {
type: REACTION_DECK_UPDATE_SUCCESS,
emojis,
skipLoading: true,
};
}
export function updateReactionDeckFail(error) {
return {
type: REACTION_DECK_UPDATE_FAIL,
error,
skipLoading: true,
};
}

View file

@ -11,6 +11,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { ScrollContext } from 'react-router-scroll-4';
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { fetchReactionDeck } from 'mastodon/actions/reaction_deck';
import { hydrateStore } from 'mastodon/actions/store';
import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary';
@ -29,6 +30,7 @@ const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
if (initialState.meta.me) {
store.dispatch(fetchCustomEmojis());
store.dispatch(fetchReactionDeck());
}
const createIdentityContext = state => ({

View file

@ -11,6 +11,7 @@ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
reaction_deck: { id: 'navigation_bar.reaction_deck', defaultMessage: 'Reaction deck' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Stamps' },
@ -44,6 +45,7 @@ class ActionBar extends PureComponent {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.reaction_deck), to: '/reaction_deck' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });

View file

@ -1,11 +1,16 @@
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { hideRecentEmojis } from 'mastodon/initial_state';
import { useEmoji } from '../../../actions/emojis';
import { changeSetting } from '../../../actions/settings';
import { shortCodes } from '../../emoji/emoji_mart_data_light';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
const perLine = 8;
const lines = 2;
@ -28,21 +33,43 @@ const DEFAULTS = [
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
const RECENT_SIZE = DEFAULTS.length;
const DECK_SIZE = 16;
if (emojis.length < DEFAULTS.length) {
let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
const getFrequentlyUsedEmojis = createSelector([
state => { return {
emojiCounters: state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
reactionDeck: state.get('reaction_deck', ImmutableList()),
}; },
], data => {
const { emojiCounters, reactionDeck } = data;
let deckEmojis = reactionDeck
.toArray()
.map((e) => e.get('emoji'))
.filter((e) => e)
.map((e) => shortCodes[e] || e);
deckEmojis = [...new Set(deckEmojis)];
let emojis;
if (!hideRecentEmojis) {
emojis = emojiCounters
.keySeq()
.filter((ee) => deckEmojis.indexOf(ee) < 0)
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < RECENT_SIZE) {
let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, RECENT_SIZE - emojis.length));
}
} else {
emojis = [];
}
emojis = deckEmojis.slice(0, DECK_SIZE).concat(emojis);
return emojis;
});

View file

@ -7,6 +7,7 @@ import { unicodeToUnifiedName } from './unicode_to_unified_name';
const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed;
const emojis = {};
const shortCodes = {};
// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
@ -33,10 +34,12 @@ Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
short_names,
unified,
};
shortCodes[native] = shortCode;
});
export {
emojis,
shortCodes,
skins,
categories,
short_names,

View file

@ -0,0 +1,75 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { updateReactionDeck } from 'mastodon/actions/reaction_deck';
import EmojiPickerDropdownContainer from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
import emojify from 'mastodon/features/emoji/emoji';
import { autoPlayGif } from 'mastodon/initial_state';
const MapStateToProps = (state, { emojiId, emojiMap }) => ({
emoji: (state.get('reaction_deck', ImmutableList()).toArray().find(em => em.get('id') === emojiId) || ImmutableMap({ emoji: '' })).get('emoji'),
emojiMap,
});
const mapDispatchToProps = (dispatch, { emojiId }) => ({
onChange: (emoji) => dispatch(updateReactionDeck(emojiId, emoji)),
});
class ReactionEmoji extends ImmutablePureComponent {
static propTypes = {
emoji: PropTypes.string,
emojiMap: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
};
static defaultProps = {
emoji: '',
};
render () {
const { emojiMap, emoji, onChange } = this.props;
let content = null;
if (emojiMap.get(emoji)) {
const filename = autoPlayGif ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
content = (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
const html = { __html: emojify(emoji) };
content = (
<span dangerouslySetInnerHTML={html} />
)
}
return (
<div className='reaction_deck__emoji'>
<div className='reaction_deck__emoji__wrapper'>
<EmojiPickerDropdownContainer onPickEmoji={onChange} />
<div>
{content}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(ReactionEmoji));

View file

@ -0,0 +1,86 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Map as ImmutableMap } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ColumnHeader from 'mastodon/components/column_header';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import Column from 'mastodon/features/ui/components/column';
import ReactionEmoji from './components/reaction_emoji';
const DECK_SIZE = 16;
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
heading: { id: 'column.reaction_deck', defaultMessage: 'Reaction deck' },
});
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
deck: state.get('reaction_deck'),
emojiMap: customEmojiMap(state),
});
class ReactionDeck extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
deck: ImmutablePropTypes.list,
emojiMap: ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
render () {
const { intl, deck, emojiMap, multiColumn } = this.props;
if (!deck) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
icon='smile-o'
title={intl.formatMessage(messages.heading)}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='reaction_deck'
bindToDocument={!multiColumn}
>
{[...Array(DECK_SIZE).keys()].map(emojiId =>
<ReactionEmoji key={emojiId} emojiMap={emojiMap} emojiId={emojiId} />
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(ReactionDeck));

View file

@ -62,6 +62,7 @@ import {
Lists,
Directory,
Explore,
ReactionDeck,
Onboarding,
About,
PrivacyPolicy,
@ -203,6 +204,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} />
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />

View file

@ -166,6 +166,10 @@ export function Onboarding () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding');
}
export function ReactionDeck () {
return import(/* webpackChunkName: "features/reaction_deck" */'../../reaction_deck');
}
export function CompareHistoryModal () {
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
}

View file

@ -60,6 +60,7 @@
* @property {string} domain
* @property {boolean} enable_login_privacy
* @property {boolean=} expand_spoilers
* @property {boolean} hide_recent_emojis
* @property {boolean} limited_federation_mode
* @property {string} locale
* @property {string | null} mascot
@ -115,6 +116,7 @@ export const domain = getMeta('domain');
export const enableLoginPrivacy = getMeta('enable_login_privacy');
export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const hideRecentEmojis = getMeta('hide_recent_emojis');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const me = getMeta('me');

View file

@ -34,6 +34,7 @@ import notifications from './notifications';
import picture_in_picture from './picture_in_picture';
import polls from './polls';
import push_notifications from './push_notifications';
import reaction_deck from './reaction_deck';
import relationships from './relationships';
import search from './search';
import server from './server';
@ -92,6 +93,7 @@ const reducers = {
history,
tags,
followed_tags,
reaction_deck,
};
const rootReducer = combineReducers(reducers);

View file

@ -0,0 +1,13 @@
import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable';
import { REACTION_DECK_FETCH_SUCCESS, REACTION_DECK_UPDATE_SUCCESS } from '../actions/reaction_deck';
const initialState = ImmutableList([]);
export default function reaction_deck(state = initialState, action) {
if(action.type === REACTION_DECK_FETCH_SUCCESS || action.type === REACTION_DECK_UPDATE_SUCCESS) {
state = ConvertToImmutable(action.emojis);
}
return state;
}

View file

@ -7544,6 +7544,26 @@ noscript {
}
}
.reaction_deck__emoji {
&__wrapper {
display: flex;
margin: 8px 4px;
height: 32px;
.emojione {
min-width: 24px;
height: 24px;
}
.emoji-button {
margin-left: 20px;
margin-right: 24px;
padding: 0;
}
}
}
.focal-point {
position: relative;
cursor: move;