diff --git a/app/controllers/api/v1/shortcuts_controller.rb b/app/controllers/api/v1/shortcuts_controller.rb new file mode 100644 index 00000000..76616023 --- /dev/null +++ b/app/controllers/api/v1/shortcuts_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class Api::V1::ShortcutsController < Api::BaseController + before_action :require_user! + before_action :set_shortcut, except: [:index, :create] + + def index + @shortcuts = Shortcut.where(account: current_account).limit(100) + + @onlyGroupIds = @shortcuts.select{ |s| s.shortcut_type == 'group' }.map(&:shortcut_id) + @onlyAccountIds = @shortcuts.select{ |s| s.shortcut_type == 'account' }.map(&:shortcut_id) + + @groups = Group.where(id: @onlyGroupIds, is_archived: false).limit(100) + @accounts = Account.where(id: @onlyAccountIds).without_suspended.limit(100) + + @final = @shortcuts.map do |s| + value = nil + title = nil + to = nil + image = nil + + if s.shortcut_type == 'group' + @group = @groups.detect{ |g| g.id == s.shortcut_id } + if @group.nil? + s.destroy! + else + value = REST::GroupSerializer.new(@group) + end + elsif s.shortcut_type == 'account' + @account = @accounts.detect{ |a| a.id == s.shortcut_id } + if @account.nil? + s.destroy! + else + value = REST::AccountSerializer.new(@account) + end + end + + r = { + id: s.id, + created_at: s.created_at, + shortcut_id: s.shortcut_id, + shortcut_type: s.shortcut_type, + shortcut: value, + } + r + end + + render json: @final + end + + def show + render json: @shortcut, serializer: REST::ShortcutSerializer + end + + def create + @shortcut = Shortcut.create!(shortcut_params.merge(account: current_account)) + + value = nil + if @shortcut.shortcut_type == 'group' + @group = Group.where(id: @shortcut.shortcut_id, is_archived: false).first + value = REST::GroupSerializer.new(@group) + elsif @shortcut.shortcut_type == 'account' + @account = Account.where(id: @shortcut.shortcut_id).without_suspended.first + value = REST::AccountSerializer.new(@account) + end + + r = { + id: @shortcut.id, + created_at: @shortcut.created_at, + shortcut_type: @shortcut.shortcut_type, + shortcut_id: @shortcut.shortcut_id, + shortcut: value, + } + + render json: r + + rescue ActiveRecord::RecordNotUnique + render json: { error: I18n.t('shortcuts.errors.exists') }, status: 422 + end + + def destroy + @shortcut.destroy! + render json: { error: false, id: params[:id] } + end + + private + + def set_shortcut + @shortcut = Shortcut.where(account: current_account).find(params[:id]) + end + + def shortcut_params + params.permit(:shortcut_type, :shortcut_id) + end +end diff --git a/app/controllers/react_controller.rb b/app/controllers/react_controller.rb index e7370177..c5e0ffcb 100644 --- a/app/controllers/react_controller.rb +++ b/app/controllers/react_controller.rb @@ -36,7 +36,7 @@ class ReactController < ApplicationController end def find_route_matches - request.path.match(/\A\/(home|group|groups|list|lists|notifications|tags|compose|follow_requests|admin|account|settings|filters|timeline|blocks|domain_blocks|mutes)/) + request.path.match(/\A\/(home|shortcuts|group|groups|list|lists|notifications|tags|compose|follow_requests|admin|account|settings|filters|timeline|blocks|domain_blocks|mutes)/) end def find_public_route_matches diff --git a/app/javascript/gabsocial/actions/shortcuts.js b/app/javascript/gabsocial/actions/shortcuts.js new file mode 100644 index 00000000..72a82bab --- /dev/null +++ b/app/javascript/gabsocial/actions/shortcuts.js @@ -0,0 +1,128 @@ +import { me } from '../initial_state' +import api from '../api' + +export const SHORTCUTS_FETCH_REQUEST = 'SHORTCUTS_FETCH_REQUEST' +export const SHORTCUTS_FETCH_SUCCESS = 'SHORTCUTS_FETCH_SUCCESS' +export const SHORTCUTS_FETCH_FAIL = 'SHORTCUTS_FETCH_FAIL' + +export const SHORTCUTS_ADD_REQUEST = 'SHORTCUTS_ADD_REQUEST' +export const SHORTCUTS_ADD_SUCCESS = 'SHORTCUTS_ADD_SUCCESS' +export const SHORTCUTS_ADD_FAIL = 'SHORTCUTS_ADD_FAIL' + +export const SHORTCUTS_REMOVE_REQUEST = 'SHORTCUTS_REMOVE_REQUEST' +export const SHORTCUTS_REMOVE_SUCCESS = 'SHORTCUTS_REMOVE_SUCCESS' +export const SHORTCUTS_REMOVE_FAIL = 'SHORTCUTS_REMOVE_FAIL' + +export function fetchShortcuts() { + return (dispatch, getState) => { + if (!me) return + + dispatch(fetchShortcutsRequest()) + + api(getState).get('/api/v1/shortcuts').then(response => { + dispatch(fetchShortcutsSuccess(response.data)) + }).catch(error => dispatch(fetchShortcutsFail(error))) + } +} + +export function fetchShortcutsRequest() { + return { + type: SHORTCUTS_FETCH_REQUEST, + } +} + +export function fetchShortcutsSuccess(shortcuts) { + return { + shortcuts, + type: SHORTCUTS_FETCH_SUCCESS, + } +} + +export function fetchShortcutsFail(error) { + return { + error, + type: SHORTCUTS_FETCH_FAIL, + } +} + +export function addShortcut(shortcutType, shortcutId) { + return (dispatch, getState) => { + if (!me) return + + dispatch(addShortcutsRequest()) + + api(getState).post('/api/v1/shortcuts', { + shortcut_type: shortcutType, + shortcut_id: shortcutId, + }).then(response => { + dispatch(addShortcutsSuccess(response.data)) + }).catch(error => dispatch(addShortcutsFail(error))) + } +} + +export function addShortcutsRequest() { + return { + type: SHORTCUTS_ADD_REQUEST, + } +} + +export function addShortcutsSuccess(shortcut) { + return { + shortcut, + type: SHORTCUTS_ADD_SUCCESS, + } +} + +export function addShortcutsFail(error) { + return { + error, + type: SHORTCUTS_ADD_FAIL, + } +} + +export function removeShortcut(shortcutObjectId, shortcutType, shortcutId) { + return (dispatch, getState) => { + if (!me) return + + let id + if (shortcutObjectId) { + shortcutObjectId = id + } else if (shortcutType && shortcutId) { + const shortcuts = getState().getIn(['shortcuts', 'items']) + const shortcut = shortcuts.find((s) => { + return s.get('shortcut_id') == shortcutId && s.get('shortcut_type') === shortcutType + }) + if (!!shortcut) { + id = shortcut.get('id') + } + } + + if (!id) return + + dispatch(removeShortcutsRequest()) + + api(getState).delete(`/api/v1/shortcuts/${id}`).then(response => { + dispatch(removeShortcutsSuccess(response.data.id)) + }).catch(error => dispatch(removeShortcutsFail(error))) + } +} + +export function removeShortcutsRequest() { + return { + type: SHORTCUTS_REMOVE_REQUEST, + } +} + +export function removeShortcutsSuccess(shortcutId) { + return { + shortcutId, + type: SHORTCUTS_REMOVE_SUCCESS, + } +} + +export function removeShortcutsFail(error) { + return { + error, + type: SHORTCUTS_REMOVE_FAIL, + } +} diff --git a/app/javascript/gabsocial/components/modal/edit_shortcuts_modal.js b/app/javascript/gabsocial/components/modal/edit_shortcuts_modal.js new file mode 100644 index 00000000..9572013b --- /dev/null +++ b/app/javascript/gabsocial/components/modal/edit_shortcuts_modal.js @@ -0,0 +1,71 @@ +import { defineMessages, injectIntl } from 'react-intl' +import ImmutablePureComponent from 'react-immutable-pure-component' +import ImmutablePropTypes from 'react-immutable-proptypes' +import { removeShortcut } from '../../actions/shortcuts' +import ModalLayout from './modal_layout' +import List from '../list' + +const messages = defineMessages({ + title: { id: 'shortcuts.edit', defaultMessage: 'Edit Shortcuts' }, + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}) + +const mapStateToProps = (state) => ({ + shortcuts: state.getIn(['shortcuts', 'items']), +}) + +const mapDispatchToProps = (dispatch) => ({ + onRemoveShortcut(shortcutId) { + dispatch(removeShortcut(shortcutId)) + }, +}) + +export default +@injectIntl +@connect(mapStateToProps, mapDispatchToProps) +class EditShortcutsModal extends ImmutablePureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + onRemoveShortcut: PropTypes.func.isRequired, + shortcuts: ImmutablePropTypes.list, + } + + handleOnRemoveShortcut = (shortcutId) => { + this.props.onRemoveShortcut(shortcutId) + } + + render() { + const { + intl, + onClose, + shortcuts, + } = this.props + + const listItems = shortcuts.map((s) => ({ + title: s.get('title'), + image: s.get('image'), + actionIcon: 'subtract', + onClick: () => this.handleOnRemoveShortcut(s.get('id')), + })) + + return ( + + + + + + ) + } + +} diff --git a/app/javascript/gabsocial/components/popover/group_options_popover.js b/app/javascript/gabsocial/components/popover/group_options_popover.js index 9567bcce..646d1e83 100644 --- a/app/javascript/gabsocial/components/popover/group_options_popover.js +++ b/app/javascript/gabsocial/components/popover/group_options_popover.js @@ -6,9 +6,12 @@ import { MODAL_GROUP_MEMBERS, MODAL_GROUP_REMOVED_ACCOUNTS, } from '../../constants' +import { + addShortcut, + removeShortcut, +} from '../../actions/shortcuts' import { openModal } from '../../actions/modal' import { closePopover } from '../../actions/popover' -import { me } from '../../initial_state' import PopoverLayout from './popover_layout' import List from '../list' @@ -16,7 +19,18 @@ const messages = defineMessages({ groupMembers: { id: 'group_members', defaultMessage: 'Group members' }, removedMembers: { id: 'group_removed_members', defaultMessage: 'Removed accounts' }, editGroup: { id: 'edit_group', defaultMessage: 'Edit group' }, -}); + add_to_shortcuts: { id: 'account.add_to_shortcuts', defaultMessage: 'Add to shortcuts' }, + remove_from_shortcuts: { id: 'account.remove_from_shortcuts', defaultMessage: 'Remove from shortcuts' }, +}) + +const mapStateToProps = (state, { group }) => { + const groupId = group ? group.get('id') : null + const shortcuts = state.getIn(['shortcuts', 'items']) + const isShortcut = !!shortcuts.find((s) => { + return s.get('shortcut_id') == groupId && s.get('shortcut_type') === 'group' + }) + return { isShortcut } +} const mapDispatchToProps = (dispatch) => ({ @@ -24,38 +38,43 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(closePopover()) dispatch(openModal(MODAL_GROUP_CREATE, { groupId })) }, - onOpenRemovedMembers(groupId) { dispatch(closePopover()) dispatch(openModal(MODAL_GROUP_REMOVED_ACCOUNTS, { groupId })) }, - onOpenGroupMembers(groupId) { dispatch(closePopover()) dispatch(openModal(MODAL_GROUP_MEMBERS, { groupId })) }, + onClosePopover: () => dispatch(closePopover()), + onAddShortcut(groupId) { + dispatch(addShortcut('group', groupId)) + }, + onRemoveShortcut(groupId) { + dispatch(removeShortcut(null, 'group', groupId)) + }, - onClosePopover: () => dispatch(closePopover()) - -}); +}) export default @injectIntl -@connect(null, mapDispatchToProps) +@connect(mapStateToProps, mapDispatchToProps) class GroupOptionsPopover extends ImmutablePureComponent { static defaultProps = { group: ImmutablePropTypes.map.isRequired, + isAdmin: PropTypes.bool, intl: PropTypes.object.isRequired, isXS: PropTypes.bool, + isShortcut: PropTypes.bool, + onAddShortcut: PropTypes.func.isRequired, + onRemoveShortcut: PropTypes.func.isRequired, onClosePopover: PropTypes.func.isRequired, onOpenEditGroup: PropTypes.func.isRequired, onOpenGroupMembers: PropTypes.func.isRequired, onOpenRemovedMembers: PropTypes.func.isRequired, } - updateOnProps = ['group'] - handleEditGroup = () => { this.props.onOpenEditGroup(this.props.group.get('id')) } @@ -72,8 +91,22 @@ class GroupOptionsPopover extends ImmutablePureComponent { this.props.onClosePopover() } + handleOnToggleShortcut = () => { + this.handleOnClosePopover() + if (this.props.isShortcut) { + this.props.onRemoveShortcut(this.props.group.get('id')) + } else { + this.props.onAddShortcut(this.props.group.get('id')) + } + } + render() { - const { intl, isXS } = this.props + const { + intl, + isAdmin, + isShortcut, + isXS, + } = this.props const listItems = [ { @@ -81,24 +114,33 @@ class GroupOptionsPopover extends ImmutablePureComponent { icon: 'group', title: intl.formatMessage(messages.groupMembers), onClick: this.handleOnOpenGroupMembers, + isHidden: !isAdmin, }, { hideArrow: true, icon: 'block', title: intl.formatMessage(messages.removedMembers), onClick: this.handleOnOpenRemovedMembers, + isHidden: !isAdmin, }, { hideArrow: true, icon: 'pencil', title: intl.formatMessage(messages.editGroup), onClick: this.handleEditGroup, - } + isHidden: !isAdmin, + }, + { + hideArrow: true, + icon: 'star', + title: intl.formatMessage(isShortcut ? messages.remove_from_shortcuts : messages.add_to_shortcuts), + onClick: this.handleOnToggleShortcut, + }, ] return ( diff --git a/app/javascript/gabsocial/components/popover/profile_options_popover.js b/app/javascript/gabsocial/components/popover/profile_options_popover.js index f0ebab4f..d2fbd004 100644 --- a/app/javascript/gabsocial/components/popover/profile_options_popover.js +++ b/app/javascript/gabsocial/components/popover/profile_options_popover.js @@ -2,23 +2,22 @@ import { defineMessages, injectIntl } from 'react-intl' import { followAccount, unfollowAccount, - blockAccount, unblockAccount, unmuteAccount, - pinAccount, - unpinAccount, } from '../../actions/accounts' import { mentionCompose, } from '../../actions/compose' -import { muteAccount } from '../../actions/accounts' +import { + addShortcut, + removeShortcut, +} from '../../actions/shortcuts' import { initReport } from '../../actions/reports' import { openModal } from '../../actions/modal' import { closePopover } from '../../actions/popover' -import { unfollowModal, autoPlayGif, me, isStaff } from '../../initial_state' +import { unfollowModal, me, isStaff } from '../../initial_state' import { makeGetAccount } from '../../selectors' import PopoverLayout from './popover_layout' -import Text from '../text' import List from '../list' const messages = defineMessages({ @@ -44,23 +43,27 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, admin_account: { id: 'admin_account', defaultMessage: 'Open moderation interface' }, add_to_list: { id: 'lists.account.add', defaultMessage: 'Add to list' }, - add_or_remove_from_shortcuts: { id: 'account.add_or_remove_from_shortcuts', defaultMessage: 'Add or Remove from shortcuts' }, + add_to_shortcuts: { id: 'account.add_to_shortcuts', defaultMessage: 'Add to shortcuts' }, + remove_from_shortcuts: { id: 'account.remove_from_shortcuts', defaultMessage: 'Remove from shortcuts' }, accountBlocked: { id: 'account.blocked', defaultMessage: 'Blocked' }, accountMuted: { id: 'account.muted', defaultMessage: 'Muted' }, }); -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); +const mapStateToProps = (state, { account }) => { + const getAccount = makeGetAccount() + const accountId = !!account ? account.get('id') : -1 + const shortcuts = state.getIn(['shortcuts', 'items']) + const isShortcut = !!shortcuts.find((s) => { + return s.get('shortcut_id') == accountId && s.get('shortcut_type') === 'account' + }) - const mapStateToProps = (state, { account }) => ({ - account: getAccount(state, !!account ? account.get('id') : -1), - }); - - return mapStateToProps; -}; + return { + isShortcut, + account: getAccount(state, accountId), + } +} const mapDispatchToProps = (dispatch, { intl }) => ({ - onFollow(account) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (unfollowModal) { @@ -74,7 +77,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(followAccount(account.get('id'))) } }, - onBlock(account) { dispatch(closePopover()) @@ -86,12 +88,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); } }, - onMention(account) { dispatch(closePopover()) dispatch(mentionCompose(account)); }, - onRepostToggle(account) { dispatch(closePopover()) if (account.getIn(['relationship', 'showing_reblogs'])) { @@ -100,12 +100,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(followAccount(account.get('id'), true)); } }, - onReport(account) { dispatch(closePopover()) dispatch(initReport(account)); }, - onMute(account) { dispatch(closePopover()) if (account.getIn(['relationship', 'muting'])) { @@ -116,30 +114,39 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })) } }, - onAddToList(account) { dispatch(closePopover()) dispatch(openModal('LIST_ADD_USER', { accountId: account.get('id'), })); }, - onClosePopover: () => dispatch(closePopover()), - -}); - + onAddShortcut(accountId) { + dispatch(closePopover()) + dispatch(addShortcut('account', accountId)) + }, + onRemoveShortcut(accountId) { + dispatch(closePopover()) + dispatch(removeShortcut(null, 'account', accountId)) + }, +}) export default @injectIntl -@connect(makeMapStateToProps, mapDispatchToProps) +@connect(mapStateToProps, mapDispatchToProps) class ProfileOptionsPopover extends PureComponent { static defaultProps = { isXS: PropTypes.bool, + isShortcut: PropTypes.bool, } makeMenu() { - const { account, intl } = this.props; + const { + account, + intl, + isShortcut, + } = this.props; let menu = []; @@ -208,12 +215,12 @@ class ProfileOptionsPopover extends PureComponent { // onClick: this.handleAddToList // }) - // menu.push({ - // hideArrow: true, - // icon: 'circle', - // title: intl.formatMessage(messages.add_or_remove_from_shortcuts), - // onClick: this.handleAddToShortcuts - // }) + menu.push({ + hideArrow: true, + icon: 'star', + title: intl.formatMessage(isShortcut ? messages.remove_from_shortcuts : messages.add_to_shortcuts), + onClick: this.handleToggleShortcuts, + }) if (isStaff) { menu.push({ @@ -259,8 +266,12 @@ class ProfileOptionsPopover extends PureComponent { this.props.onAddToList(this.props.account); } - handleAddToShortcuts = () => { - // : todo : + handleToggleShortcuts = () => { + if (this.props.isShortcut) { + this.props.onRemoveShortcut(this.props.account.get('id')) + } else { + this.props.onAddShortcut(this.props.account.get('id')) + } } handleOnClosePopover = () => { diff --git a/app/javascript/gabsocial/components/sidebar.js b/app/javascript/gabsocial/components/sidebar.js index 761ade7f..6ff81608 100644 --- a/app/javascript/gabsocial/components/sidebar.js +++ b/app/javascript/gabsocial/components/sidebar.js @@ -1,11 +1,15 @@ +import { Fragment } from 'react' import ImmutablePropTypes from 'react-immutable-proptypes' import ImmutablePureComponent from 'react-immutable-pure-component' import { injectIntl, defineMessages } from 'react-intl' -import * as Constants from '../constants' +import { + BREAKPOINT_SMALL, +} from '../constants' import Button from './button' import { closeSidebar } from '../actions/sidebar' import { openModal } from '../actions/modal' import { openPopover } from '../actions/popover' +import { fetchShortcuts } from '../actions/shortcuts' import { me } from '../initial_state' import { makeGetAccount } from '../selectors' import Responsive from '../features/ui/util/responsive_component' @@ -14,6 +18,7 @@ import SidebarSectionItem from './sidebar_section_item' import Heading from './heading' import BackButton from './back_button' import Pills from './pills' +import Text from './text' const messages = defineMessages({ followers: { id: 'account.followers', defaultMessage: 'Followers' }, @@ -35,10 +40,14 @@ const messages = defineMessages({ search: { id: 'tabs_bar.search', defaultMessage: 'Search' }, shop: { id: 'tabs_bar.shop', defaultMessage: 'Store - Buy Merch' }, donate: { id: 'tabs_bar.donate', defaultMessage: 'Make a Donation' }, + shortcuts: { id: 'navigation_bar.shortcuts', defaultMessage: 'Shortcuts' }, + all: { id: 'all', defaultMessage: 'All' }, + edit: { id: 'edit', defaultMessage: 'Edit' }, }) const mapStateToProps = (state) => ({ account: makeGetAccount()(state, me), + shortcuts: state.getIn(['shortcuts', 'items']), moreOpen: state.getIn(['popover', 'popoverType']) === 'SIDEBAR_MORE', notificationCount: state.getIn(['notifications', 'unread']), homeItemsQueueCount: state.getIn(['timelines', 'home', 'totalQueuedItemsCount']), @@ -54,6 +63,9 @@ const mapDispatchToProps = (dispatch) => ({ onOpenComposeModal() { dispatch(openModal('COMPOSE')) }, + onFetchShortcuts() { + dispatch(fetchShortcuts()) + }, }) export default @@ -67,6 +79,7 @@ class Sidebar extends ImmutablePureComponent { moreOpen: PropTypes.bool, onClose: PropTypes.func.isRequired, onOpenComposeModal: PropTypes.func.isRequired, + onFetchShortcuts: PropTypes.func.isRequired, openSidebarMorePopover: PropTypes.func.isRequired, notificationCount: PropTypes.number.isRequired, homeItemsQueueCount: PropTypes.number.isRequired, @@ -74,6 +87,15 @@ class Sidebar extends ImmutablePureComponent { tabs: PropTypes.array, title: PropTypes.string, showBackBtn: PropTypes.bool, + shortcuts: ImmutablePropTypes.list, + } + + state = { + hoveringShortcuts: false, + } + + componentDidMount() { + this.props.onFetchShortcuts() } handleOpenComposeModal = () => { @@ -87,6 +109,14 @@ class Sidebar extends ImmutablePureComponent { }) } + handleMouseEnterShortcuts = () => { + this.setState({ hoveringShortcuts: true }) + } + + handleMouseLeaveShortcuts = () => { + this.setState({ hoveringShortcuts: false }) + } + setMoreButtonRef = n => { this.moreBtnRef = n } @@ -102,14 +132,12 @@ class Sidebar extends ImmutablePureComponent { tabs, title, showBackBtn, + shortcuts, } = this.props + const { hoveringShortcuts } = this.state - // : todo : if (!me || !account) return null - const acct = account.get('acct') - const isPro = account.get('is_pro') - const menuItems = [ { title: 'Home', @@ -144,6 +172,11 @@ class Sidebar extends ImmutablePureComponent { icon: 'explore', to: '/explore', }, + { + title: 'Pro Feed', + icon: 'circle', + to: '/timeline/pro', + }, { title: 'More', icon: 'more', @@ -153,32 +186,18 @@ class Sidebar extends ImmutablePureComponent { }, ] - const shortcutItems = [ - // { - // title: 'Meme Group', - // icon: 'group', - // to: '/', - // count: 0, - // }, - // { - // title: '@andrew', - // image: 'http://localhost:3000/system/accounts/avatars/000/000/001/original/260e8c96c97834da.jpeg?1562898139', - // to: '/', - // count: 3, - // }, - ] + let shortcutItems = [] + if (!!shortcuts) { + shortcuts.forEach((s) => { + shortcutItems.push({ + to: s.get('to'), + title: s.get('title'), + image: s.get('image'), + }) + }) + } const exploreItems = [ - { - title: 'Pro Feed', - icon: 'circle', - to: '/timeline/pro', - }, - { - title: 'Chat', - icon: 'chat', - href: 'https://chat.gab.com', - }, { title: 'Apps', icon: 'apps', @@ -259,6 +278,41 @@ class Sidebar extends ImmutablePureComponent { ) }) } + { + !!shortcutItems.length > 0 && + + + + + {intl.formatMessage(messages.shortcuts)} + + + { + hoveringShortcuts && + + {intl.formatMessage(messages.all)} + + } + + + + { + shortcutItems.map((shortcutItem, i) => ( + + )) + } + + } {intl.formatMessage(messages.explore)} { exploreItems.map((exploreItem, i) => ( @@ -267,7 +321,7 @@ class Sidebar extends ImmutablePureComponent { } - + - + ({ @@ -101,6 +102,12 @@ class SidebarXS extends ImmutablePureComponent { onClick: this.handleSidebarClose, title: intl.formatMessage(messages.lists), }, + { + icon: 'star', + to: '/shortcuts', + onClick: this.handleSidebarClose, + title: intl.formatMessage(messages.shortcuts), + }, { icon: 'pro', href: 'https://pro.gab.com', diff --git a/app/javascript/gabsocial/constants.js b/app/javascript/gabsocial/constants.js index afb41c46..a46848ee 100644 --- a/app/javascript/gabsocial/constants.js +++ b/app/javascript/gabsocial/constants.js @@ -41,6 +41,7 @@ export const MODAL_COMPOSE = 'COMPOSE' export const MODAL_CONFIRM = 'CONFIRM' export const MODAL_DISPLAY_OPTIONS = 'DISPLAY_OPTIONS' export const MODAL_EDIT_PROFILE = 'EDIT_PROFILE' +export const MODAL_EDIT_SHORTCUTS = 'EDIT_SHORTCUTS' export const MODAL_EMBED = 'EMBED' export const MODAL_GIF_PICKER = 'GIF_PICKER' export const MODAL_GROUP_CREATE = 'GROUP_CREATE' diff --git a/app/javascript/gabsocial/features/shortcuts.js b/app/javascript/gabsocial/features/shortcuts.js new file mode 100644 index 00000000..93362f54 --- /dev/null +++ b/app/javascript/gabsocial/features/shortcuts.js @@ -0,0 +1,62 @@ +import ImmutablePureComponent from 'react-immutable-pure-component' +import ImmutablePropTypes from 'react-immutable-proptypes' +import { fetchShortcuts } from '../actions/shortcuts' +import ColumnIndicator from '../components/column_indicator' +import List from '../components/list' + +const mapStateToProps = (state) => ({ + isError: state.getIn(['shortcuts', 'isError']), + isLoading: state.getIn(['shortcuts', 'isLoading']), + shortcuts: state.getIn(['shortcuts', 'items']), +}) + +const mapDispatchToProps = (dispatch) => ({ + onFetchShortcuts() { + dispatch(fetchShortcuts()) + }, +}) + +export default +@connect(mapStateToProps, mapDispatchToProps) +class Shortcuts extends ImmutablePureComponent { + + static propTypes = { + isLoading: PropTypes.bool.isRequired, + isError: PropTypes.bool.isRequired, + onFetchShortcuts: PropTypes.func.isRequired, + shortcuts: ImmutablePropTypes.list, + } + + componentDidMount() { + this.props.onFetchShortcuts() + } + + render() { + const { + isLoading, + isError, + shortcuts, + } = this.props + + if (isLoading) { + return + } else if (isError) { + return + } + + const listItems = shortcuts.map((s) => ({ + to: s.get('to'), + title: s.get('title'), + image: s.get('image'), + })) + + return ( + + ) + } + +} \ No newline at end of file diff --git a/app/javascript/gabsocial/features/ui/ui.js b/app/javascript/gabsocial/features/ui/ui.js index b2b9b90f..bf3153c9 100644 --- a/app/javascript/gabsocial/features/ui/ui.js +++ b/app/javascript/gabsocial/features/ui/ui.js @@ -75,7 +75,7 @@ import { PrivacyPolicy, ProTimeline, Search, - // Shortcuts, + Shortcuts, StatusFeature, StatusLikes, StatusReposts, @@ -188,7 +188,7 @@ class SwitchingArea extends PureComponent { - { /* */ } + diff --git a/app/javascript/gabsocial/features/ui/util/async_components.js b/app/javascript/gabsocial/features/ui/util/async_components.js index 5410cac4..1005408e 100644 --- a/app/javascript/gabsocial/features/ui/util/async_components.js +++ b/app/javascript/gabsocial/features/ui/util/async_components.js @@ -15,6 +15,7 @@ export function DatePickerPopover() { return import(/* webpackChunkName: "compon export function DisplayOptionsModal() { return import(/* webpackChunkName: "components/display_options_modal" */'../../../components/modal/display_options_modal') } export function DMCA() { return import(/* webpackChunkName: "features/about/dmca" */'../../about/dmca') } export function EditProfileModal() { return import(/* webpackChunkName: "components/edit_profile_modal" */'../../../components/modal/edit_profile_modal') } +export function EditShortcutsModal() { return import(/* webpackChunkName: "components/edit_shortcuts_modal" */'../../../components/modal/edit_shortcuts_modal') } export function EmbedModal() { return import(/* webpackChunkName: "modals/embed_modal" */'../../../components/modal/embed_modal') } export function EmojiPicker() { return import(/* webpackChunkName: "emoji_picker" */'../../../components/emoji/emoji_picker') } export function EmojiPickerPopover() { return import(/* webpackChunkName: "components/emoji_picker_popover" */'../../../components/popover/emoji_picker_popover') } @@ -64,6 +65,7 @@ export function ProfileOptionsPopover() { return import(/* webpackChunkName: "co export function ProUpgradeModal() { return import(/* webpackChunkName: "components/pro_upgrade_modal" */'../../../components/modal/pro_upgrade_modal') } export function ReportModal() { return import(/* webpackChunkName: "modals/report_modal" */'../../../components/modal/report_modal') } export function Search() { return import(/*webpackChunkName: "features/search" */'../../search') } +export function Shortcuts() { return import(/*webpackChunkName: "features/shortcuts" */'../../shortcuts') } export function Status() { return import(/* webpackChunkName: "components/status" */'../../../components/status') } export function StatusFeature() { return import(/* webpackChunkName: "features/status" */'../../status') } export function SearchPopover() { return import(/* webpackChunkName: "components/search_popover" */'../../../components/popover/search_popover') } diff --git a/app/javascript/gabsocial/pages/shortcuts_page.js b/app/javascript/gabsocial/pages/shortcuts_page.js index edbf0932..9fa58ee5 100644 --- a/app/javascript/gabsocial/pages/shortcuts_page.js +++ b/app/javascript/gabsocial/pages/shortcuts_page.js @@ -1,34 +1,58 @@ -import { Fragment } from 'react' +import { defineMessages, injectIntl } from 'react-intl' import { openModal } from '../actions/modal' -import GroupSidebarPanel from '../components/panel/groups_panel' +import { MODAL_EDIT_SHORTCUTS } from '../constants' +import PageTitle from '../features/ui/util/page_title' import LinkFooter from '../components/link_footer' import WhoToFollowPanel from '../components/panel/who_to_follow_panel' -import ProgressPanel from '../components/panel/progress_panel' -import UserPanel from '../components/panel/user_panel' import TrendsPanel from '../components/panel/trends_panel' import DefaultLayout from '../layouts/default_layout' +const messages = defineMessages({ + shortcuts: { id: 'shortcuts', defaultMessage: 'Shortcuts' }, +}) + +const mapDispatchToProps = (dispatch) => ({ + onOpenEditShortcutsModal() { + dispatch(openModal(MODAL_EDIT_SHORTCUTS)) + }, +}) + export default +@injectIntl +@connect(null, mapDispatchToProps) class ShortcutsPage extends PureComponent { + static propTypes = { + intl: PropTypes.object.isRequired, + onOpenEditShortcutsModal: PropTypes.func.isRequired, + } + + handleOnOpenEditShortcutsModal = () => { + this.props.onOpenEditShortcutsModal() + } + render() { - const { children } = this.props + const { intl, children } = this.props + + const title = intl.formatMessage(messages.shortcuts) return ( - - - - - - - - )} + title={title} + page='shortcuts' + actions={[ + { + icon: 'cog', + onClick: this.handleOnOpenEditShortcutsModal, + }, + ]} + layout={[ + , + , + , + ]} > + {children} ) diff --git a/app/javascript/gabsocial/reducers/index.js b/app/javascript/gabsocial/reducers/index.js index a4e85e4c..2879359a 100644 --- a/app/javascript/gabsocial/reducers/index.js +++ b/app/javascript/gabsocial/reducers/index.js @@ -30,6 +30,7 @@ import reports from './reports' import search from './search' import settings from './settings' import shop from './shop' +import shortcuts from './shortcuts' import sidebar from './sidebar' import statuses from './statuses' import status_lists from './status_lists' @@ -72,6 +73,7 @@ const reducers = { search, settings, shop, + shortcuts, sidebar, statuses, status_lists, diff --git a/app/javascript/gabsocial/reducers/shortcuts.js b/app/javascript/gabsocial/reducers/shortcuts.js new file mode 100644 index 00000000..9da61e86 --- /dev/null +++ b/app/javascript/gabsocial/reducers/shortcuts.js @@ -0,0 +1,75 @@ +import { + SHORTCUTS_FETCH_REQUEST, + SHORTCUTS_FETCH_SUCCESS, + SHORTCUTS_FETCH_FAIL, + SHORTCUTS_ADD_SUCCESS, + SHORTCUTS_REMOVE_SUCCESS, +} from '../actions/shortcuts' +import { importFetchedAccount } from '../actions/importer' +import { importGroup } from '../actions/groups' +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable' + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + isError: false, +}) + +const normalizeShortcut = (shortcut) => { + if (shortcut.shortcut_type === 'account') { + importFetchedAccount(shortcut.shortcut) + return { + id: shortcut.id, + shortcut_type: 'account', + shortcut_id: shortcut.shortcut_id, + title: shortcut.shortcut.acct, + image: shortcut.shortcut.avatar_static, + to: `/${shortcut.shortcut.acct}`, + } + } else if (shortcut.shortcut_type === 'group') { + importGroup(shortcut.shortcut) + return { + id: shortcut.id, + shortcut_type: 'group', + shortcut_id: shortcut.shortcut_id, + title: shortcut.shortcut.title, + image: shortcut.shortcut.cover_image_url, + to: `/groups/${shortcut.shortcut.id}`, + } + } +} + +const normalizeShortcuts = (shortcuts) => { + return fromJS(shortcuts.map((shortcut) => { + return normalizeShortcut(shortcut) + })) +} + +export default function shortcutsReducer(state = initialState, action) { + switch(action.type) { + case SHORTCUTS_FETCH_REQUEST: + return state.withMutations((map) => { + map.set('isLoading', true) + map.set('isError', false) + }) + case SHORTCUTS_FETCH_SUCCESS: + return state.withMutations((map) => { + map.set('items', normalizeShortcuts(action.shortcuts)) + map.set('isLoading', false) + map.set('isError', false) + }) + case SHORTCUTS_FETCH_FAIL: + return state.withMutations((map) => { + map.set('isLoading', false) + map.set('isError', true) + }) + case SHORTCUTS_ADD_SUCCESS: + return state.update('items', list => list.push(fromJS(normalizeShortcut(action.shortcut)))) + case SHORTCUTS_REMOVE_SUCCESS: + return state.update('items', list => list.filterNot((item) => { + return `${item.get('id')}` === `${action.shortcutId}` + })) + default: + return state + } +} diff --git a/app/models/shortcut.rb b/app/models/shortcut.rb index 2245249c..49869268 100644 --- a/app/models/shortcut.rb +++ b/app/models/shortcut.rb @@ -3,14 +3,25 @@ # # Table name: shortcuts # -# id :bigint(8) not null, primary key -# account_id :bigint(8) not null -# shortcut_id :bigint(8) not null -# shortcut_type :string not null -# created_at :datetime not null -# updated_at :datetime +# id :bigint(8) not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) not null +# shortcut_id :bigint(8) not null +# shortcut_type :string default(""), not null # class Shortcut < ApplicationRecord - + # enum shortcut_type: { + # account: 'account', + # group: 'group' + # } + + belongs_to :account + + PER_ACCOUNT_LIMIT = 50 + + validates_each :account_id, on: :create do |record, _attr, value| + record.errors.add(:base, I18n.t('shortcuts.errors.limit')) if Shortcut.where(account_id: value).count >= PER_ACCOUNT_LIMIT + end end diff --git a/app/serializers/rest/shortcut_serializer.rb b/app/serializers/rest/shortcut_serializer.rb new file mode 100644 index 00000000..66d34da5 --- /dev/null +++ b/app/serializers/rest/shortcut_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class REST::ShortcutSerializer < ActiveModel::Serializer + attributes :id, :account_id, :created_at, :shortcut_type, :shortcut_id + + def id + object.id.to_s + end + +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 95d8de76..b5f2088d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -837,6 +837,7 @@ en: shortcuts: errors: limit: You have reached the maximum amount of shortcuts + exists: You already have a shortcut for that sessions: activity: Last activity browser: Browser diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 3b4dddc3..3901c35d 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -795,6 +795,7 @@ en_GB: shortcuts: errors: limit: You have reached the maximum amount of shortcuts + exists: You already have a shortcut for that sessions: activity: Last activity browser: Browser diff --git a/config/routes.rb b/config/routes.rb index 97337cc8..e74c0828 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -359,6 +359,7 @@ Rails.application.routes.draw do resources :reports, only: [:create] resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] + resources :shortcuts, only: [:index, :create, :show, :destroy] namespace :apps do get :verify_credentials, to: 'credentials#show'