diff --git a/app/controllers/api/v1/emoji_reactions_controller.rb b/app/controllers/api/v1/emoji_reactions_controller.rb new file mode 100644 index 0000000000..a0f4e59ccf --- /dev/null +++ b/app/controllers/api/v1/emoji_reactions_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Api::V1::EmojiReactionsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:favourites' } + before_action :require_user! + after_action :insert_pagination_headers + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_emoji_reactions + end + + def cached_emoji_reactions + cache_collection(results.map(&:status), EmojiReaction) + end + + def results + @_results ||= account_emoji_reactions.joins(:status).eager_load(:status).to_a_paginated_by_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def account_emoji_reactions + current_account.emoji_reactions + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_emoji_reactions_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_emoji_reactions_url pagination_params(min_id: pagination_since_id) unless results.empty? + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/javascript/mastodon/actions/emoji_reactions.js b/app/javascript/mastodon/actions/emoji_reactions.js new file mode 100644 index 0000000000..a58e53d2fe --- /dev/null +++ b/app/javascript/mastodon/actions/emoji_reactions.js @@ -0,0 +1,94 @@ +import api, { getLinks } from '../api'; +import { importFetchedStatuses } from './importer'; + +export const EMOJI_REACTED_STATUSES_FETCH_REQUEST = 'EMOJI_REACTED_STATUSES_FETCH_REQUEST'; +export const EMOJI_REACTED_STATUSES_FETCH_SUCCESS = 'EMOJI_REACTED_STATUSES_FETCH_SUCCESS'; +export const EMOJI_REACTED_STATUSES_FETCH_FAIL = 'EMOJI_REACTED_STATUSES_FETCH_FAIL'; + +export const EMOJI_REACTED_STATUSES_EXPAND_REQUEST = 'EMOJI_REACTED_STATUSES_EXPAND_REQUEST'; +export const EMOJI_REACTED_STATUSES_EXPAND_SUCCESS = 'EMOJI_REACTED_STATUSES_EXPAND_SUCCESS'; +export const EMOJI_REACTED_STATUSES_EXPAND_FAIL = 'EMOJI_REACTED_STATUSES_EXPAND_FAIL'; + +export function fetchEmojiReactedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) { + return; + } + + dispatch(fetchEmojiReactedStatusesRequest()); + + api(getState).get('/api/v1/emoji_reactions').then(response => { + console.dir(response.data) + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchEmojiReactedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchEmojiReactedStatusesFail(error)); + }); + }; +} + +export function fetchEmojiReactedStatusesRequest() { + return { + type: EMOJI_REACTED_STATUSES_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchEmojiReactedStatusesSuccess(statuses, next) { + return { + type: EMOJI_REACTED_STATUSES_FETCH_SUCCESS, + statuses, + next, + skipLoading: true, + }; +} + +export function fetchEmojiReactedStatusesFail(error) { + return { + type: EMOJI_REACTED_STATUSES_FETCH_FAIL, + error, + skipLoading: true, + }; +} + +export function expandEmojiReactedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'emoji_reactions', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) { + return; + } + + dispatch(expandEmojiReactedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandEmojiReactedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandEmojiReactedStatusesFail(error)); + }); + }; +} + +export function expandEmojiReactedStatusesRequest() { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_REQUEST, + }; +} + +export function expandEmojiReactedStatusesSuccess(statuses, next) { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +} + +export function expandEmojiReactedStatusesFail(error) { + return { + type: EMOJI_REACTED_STATUSES_EXPAND_FAIL, + error, + }; +} diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx index ee584cb1ba..714071c875 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx @@ -10,6 +10,7 @@ const messages = defineMessages({ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, 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: 'Emoji Reactions' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, @@ -44,6 +45,7 @@ class ActionBar extends React.PureComponent { menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); + menu.push({ text: intl.formatMessage(messages.emoji_reactions), to: '/emoji_reactions' }); menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); diff --git a/app/javascript/mastodon/features/emoji_reacted_statuses/index.jsx b/app/javascript/mastodon/features/emoji_reacted_statuses/index.jsx new file mode 100644 index 0000000000..6d878d3da3 --- /dev/null +++ b/app/javascript/mastodon/features/emoji_reacted_statuses/index.jsx @@ -0,0 +1,108 @@ +import { debounce } from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { fetchEmojiReactedStatuses, expandEmojiReactedStatuses } from 'mastodon/actions/emoji_reactions'; +import ColumnHeader from 'mastodon/components/column_header'; +import StatusList from 'mastodon/components/status_list'; +import Column from 'mastodon/features/ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.emoji_reactions', defaultMessage: 'Emoji Reactions' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'emoji_reactions', 'items']), + isLoading: state.getIn(['status_lists', 'emoji_reactions', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'emoji_reactions', 'next']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class EmojiReactions extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchEmojiReactedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('EMOJI_REACTIONS', {})); + } + }; + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + setRef = c => { + this.column = c; + }; + + handleLoadMore = debounce(() => { + this.props.dispatch(expandEmojiReactedStatuses()); + }, 300, { leading: true }); + + render () { + const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + const emptyMessage = ; + + return ( + + + + + + + {intl.formatMessage(messages.heading)} + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index c1d346fed4..24f0bdf21f 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -111,6 +111,11 @@ class InteractionModal extends React.PureComponent { title = ; actionDescription = ; break; + case 'emoji_reaction': + icon = ; + title = ; + actionDescription = ; + break; case 'follow': icon = ; title = ; diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx index 9a870478da..9100f1da78 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx @@ -138,6 +138,10 @@ class KeyboardShortcuts extends ImmutablePureComponent { g+f + + g+e + + g+p diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 161d105451..593656f203 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -258,7 +258,17 @@ class Status extends ImmutablePureComponent { handleEmojiReact = (status, emoji) => { const { dispatch } = this.props; - dispatch(emojiReact(status, emoji)); + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(emojiReact(status, emoji)); + } else { + dispatch(openModal('INTERACTION', { + type: 'favourite', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); + } }; handleUnEmojiReact = (status, emoji) => { diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 1dd6e34e88..1ce14b4c5b 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -15,6 +15,7 @@ import { HashtagTimeline, DirectTimeline, FavouritedStatuses, + EmojiReactedStatuses, BookmarkedStatuses, ListTimeline, Directory, @@ -34,6 +35,7 @@ const componentMap = { 'HASHTAG': HashtagTimeline, 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, + 'EMOJI_REACTIONS': EmojiReactedStatuses, 'BOOKMARKS': BookmarkedStatuses, 'LIST': ListTimeline, 'DIRECTORY': Directory, diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 2afde63a38..de610059b0 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -42,6 +42,7 @@ import { Notifications, FollowRequests, FavouritedStatuses, + EmojiReactedStatuses, BookmarkedStatuses, FollowedTags, ListTimeline, @@ -102,6 +103,7 @@ const keyMap = { goToDirect: 'g d', goToStart: 'g s', goToFavourites: 'g f', + goToEmojiReactions: 'g e', goToPinned: 'g p', goToProfile: 'g u', goToBlocked: 'g b', @@ -189,6 +191,7 @@ class SwitchingColumnsArea extends React.PureComponent { + @@ -525,6 +528,10 @@ class UI extends React.PureComponent { this.context.router.history.push('/favourites'); }; + handleHotkeyGoToEmojiReactions = () => { + this.context.router.history.push('/emoji_reactions'); + }; + handleHotkeyGoToPinned = () => { this.context.router.history.push('/pinned'); }; @@ -564,6 +571,7 @@ class UI extends React.PureComponent { goToDirect: this.handleHotkeyGoToDirect, goToStart: this.handleHotkeyGoToStart, goToFavourites: this.handleHotkeyGoToFavourites, + goToEmojiReactions: this.handleHotkeyGoToEmojiReactions, goToPinned: this.handleHotkeyGoToPinned, goToProfile: this.handleHotkeyGoToProfile, goToBlocked: this.handleHotkeyGoToBlocked, diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5e175c9413..71ab6a67c5 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -94,6 +94,10 @@ export function FavouritedStatuses () { return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses'); } +export function EmojiReactedStatuses () { + return import(/* webpackChunkName: "features/emoji_reacted_statuses" */'../../emoji_reacted_statuses'); +} + export function FollowedTags () { return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags'); } diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index b1716e9cf5..508637b8be 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -6,6 +6,14 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, FAVOURITED_STATUSES_EXPAND_FAIL, } from '../actions/favourites'; +import { + EMOJI_REACTED_STATUSES_FETCH_REQUEST, + EMOJI_REACTED_STATUSES_FETCH_SUCCESS, + EMOJI_REACTED_STATUSES_FETCH_FAIL, + EMOJI_REACTED_STATUSES_EXPAND_REQUEST, + EMOJI_REACTED_STATUSES_EXPAND_SUCCESS, + EMOJI_REACTED_STATUSES_EXPAND_FAIL, +} from '../actions/emoji_reactions'; import { BOOKMARKED_STATUSES_FETCH_REQUEST, BOOKMARKED_STATUSES_FETCH_SUCCESS, @@ -29,6 +37,8 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl import { FAVOURITE_SUCCESS, UNFAVOURITE_SUCCESS, + EMOJIREACT_SUCCESS, + UNEMOJIREACT_SUCCESS, BOOKMARK_SUCCESS, UNBOOKMARK_SUCCESS, PIN_SUCCESS, @@ -45,6 +55,11 @@ const initialState = ImmutableMap({ loaded: false, items: ImmutableOrderedSet(), }), + emoji_reactions: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableOrderedSet(), + }), bookmarks: ImmutableMap({ next: null, loaded: false, @@ -105,6 +120,16 @@ export default function statusLists(state = initialState, action) { return normalizeList(state, 'favourites', action.statuses, action.next); case FAVOURITED_STATUSES_EXPAND_SUCCESS: return appendToList(state, 'favourites', action.statuses, action.next); + case EMOJI_REACTED_STATUSES_FETCH_REQUEST: + case EMOJI_REACTED_STATUSES_EXPAND_REQUEST: + return state.setIn(['emoji_reactions', 'isLoading'], true); + case EMOJI_REACTED_STATUSES_FETCH_FAIL: + case EMOJI_REACTED_STATUSES_EXPAND_FAIL: + return state.setIn(['emoji_reactions', 'isLoading'], false); + case EMOJI_REACTED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'emoji_reactions', action.statuses, action.next); + case EMOJI_REACTED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'emoji_reactions', action.statuses, action.next); case BOOKMARKED_STATUSES_FETCH_REQUEST: case BOOKMARKED_STATUSES_EXPAND_REQUEST: return state.setIn(['bookmarks', 'isLoading'], true); @@ -129,6 +154,10 @@ export default function statusLists(state = initialState, action) { return prependOneToList(state, 'favourites', action.status); case UNFAVOURITE_SUCCESS: return removeOneFromList(state, 'favourites', action.status); + case EMOJIREACT_SUCCESS: + return prependOneToList(state, 'emoji_reactions', action.status); + case UNEMOJIREACT_SUCCESS: + return removeOneFromList(state, 'emoji_reactions', action.status); case BOOKMARK_SUCCESS: return prependOneToList(state, 'bookmarks', action.status); case UNBOOKMARK_SUCCESS: diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 58828b0c4c..f4b872d7c5 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -33,7 +33,7 @@ class CustomEmoji < ApplicationRecord SCAN_RE = /:(#{SHORTCODE_RE_FRAGMENT}):/x SHORTCODE_ONLY_RE = /\A#{SHORTCODE_RE_FRAGMENT}\z/ - IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze + IMAGE_MIME_TYPES = %w(image/png image/gif image/webp image/jpeg).freeze belongs_to :category, class_name: 'CustomEmojiCategory', optional: true has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb index 44e880d9fc..85d2914d13 100644 --- a/app/models/emoji_reaction.rb +++ b/app/models/emoji_reaction.rb @@ -18,7 +18,7 @@ class EmojiReaction < ApplicationRecord include Paginable EMOJI_REACTION_LIMIT = 32767 - EMOJI_REACTION_PER_ACCOUNT_LIMIT = 5 + EMOJI_REACTION_PER_ACCOUNT_LIMIT = 3 update_index('statuses', :status) diff --git a/config/routes.rb b/config/routes.rb index 52321c8484..4db9dca85c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -529,16 +529,17 @@ Rails.application.routes.draw do end end - resources :media, only: [:create, :update, :show] - resources :blocks, only: [:index] - resources :mutes, only: [:index] - resources :favourites, only: [:index] - resources :bookmarks, only: [:index] - resources :reports, only: [:create] - resources :trends, only: [:index], controller: 'trends/tags' - resources :filters, only: [:index, :create, :show, :update, :destroy] - resources :endorsements, only: [:index] - resources :markers, only: [:index, :create] + resources :media, only: [:create, :update, :show] + resources :blocks, only: [:index] + resources :mutes, only: [:index] + resources :favourites, only: [:index] + resources :emoji_reactions, only: [:index] + resources :bookmarks, only: [:index] + resources :reports, only: [:create] + resources :trends, only: [:index], controller: 'trends/tags' + resources :filters, only: [:index, :create, :show, :update, :destroy] + resources :endorsements, only: [:index] + resources :markers, only: [:index, :create] namespace :apps do get :verify_credentials, to: 'credentials#show'