diff options
11 files changed, 225 insertions, 31 deletions
diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js index 7c73002c1..d4d26c62c 100644 --- a/app/javascript/glitch/components/status/action_bar.js +++ b/app/javascript/glitch/components/status/action_bar.js @@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; // Mastodon imports // import RelativeTimestamp from '../../../mastodon/components/relative_timestamp'; import IconButton from '../../../mastodon/components/icon_button'; -import DropdownMenu from '../../../mastodon/components/dropdown_menu'; +import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -16,6 +16,7 @@ const messages = defineMessages({ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, @@ -24,6 +25,9 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -43,7 +47,9 @@ export default class StatusActionBar extends ImmutablePureComponent { onMute: PropTypes.func, onBlock: PropTypes.func, onReport: PropTypes.func, + onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -61,6 +67,13 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onReply(this.props.status, this.context.router.history); } + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); + } + handleFavouriteClick = () => { this.props.onFavourite(this.props.status); } @@ -73,6 +86,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -89,6 +106,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + handleReport = () => { this.props.onReport(this.props.status); } @@ -99,9 +120,10 @@ export default class StatusActionBar extends ImmutablePureComponent { render () { const { status, me, intl, withDismiss } = this.props; - const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); let menu = []; let reblogIcon = 'retweet'; @@ -109,14 +131,23 @@ export default class StatusActionBar extends ImmutablePureComponent { let replyTitle; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + menu.push(null); - if (withDismiss) { + if (status.getIn(['account', 'id']) === me || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); } if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); @@ -126,14 +157,6 @@ export default class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); } - /* - if (status.get('visibility') === 'direct') { - reblogIcon = 'envelope'; - } else if (status.get('visibility') === 'private') { - reblogIcon = 'lock'; - } - */ - if (status.get('in_reply_to_id', null) === null) { replyIcon = 'reply'; replyTitle = intl.formatMessage(messages.reply); @@ -142,14 +165,19 @@ export default class StatusActionBar extends ImmutablePureComponent { replyTitle = intl.formatMessage(messages.replyAll); } + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> + ); + return ( <div className='status__action-bar'> <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> - <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> - <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> + <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + {shareButton} <div className='status__action-bar-dropdown'> - <DropdownMenu items={menu} disabled={anonymousAccess} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> + <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> </div> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js index b4d7fb4cc..da2771c0b 100644 --- a/app/javascript/glitch/components/status/container.js +++ b/app/javascript/glitch/components/status/container.js @@ -38,6 +38,8 @@ import { favourite, unreblog, unfavourite, + pin, + unpin, } from '../../../mastodon/actions/interactions'; import { blockAccount } from '../../../mastodon/actions/accounts'; import { initMuteModal } from '../../../mastodon/actions/mutes'; @@ -187,6 +189,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { url: status.get('url') })); + }, + onDelete (status) { if (!this.deleteModal) { dispatch(deleteStatus(status.get('id'))); diff --git a/app/javascript/glitch/components/status/gallery/item.js b/app/javascript/glitch/components/status/gallery/item.js index d646825a3..ab4aab8dc 100644 --- a/app/javascript/glitch/components/status/gallery/item.js +++ b/app/javascript/glitch/components/status/gallery/item.js @@ -17,6 +17,24 @@ export default class StatusGalleryItem extends React.PureComponent { autoPlayGif: PropTypes.bool.isRequired, }; + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment, autoPlayGif } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + handleClick = (e) => { const { index, onClick } = this.props; @@ -112,6 +130,8 @@ export default class StatusGalleryItem extends React.PureComponent { role='application' src={attachment.get('url')} onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} autoPlay={autoPlay} loop muted diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js index 55e6f1876..4a2a0e1d4 100644 --- a/app/javascript/glitch/components/status/index.js +++ b/app/javascript/glitch/components/status/index.js @@ -165,10 +165,13 @@ export default class Status extends ImmutablePureComponent { onReblog : PropTypes.func, onModalReblog : PropTypes.func, onDelete : PropTypes.func, + onPin : PropTypes.func, onMention : PropTypes.func, onMute : PropTypes.func, onMuteConversation : PropTypes.func, onBlock : PropTypes.func, + onEmbed : PropTypes.func, + onHeightChange : PropTypes.func, onReport : PropTypes.func, onOpenMedia : PropTypes.func, onOpenVideo : PropTypes.func, diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 5f265a750..c86184046 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,4 +1,5 @@ import api from '../api'; +import emojione from 'emojione'; import { updateTimeline, @@ -22,6 +23,7 @@ export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +export const COMPOSE_SUGGESTIONS_READY_TXT = 'COMPOSE_SUGGESTIONS_READY_TXT'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; @@ -211,18 +213,56 @@ export function clearComposeSuggestions() { }; }; +let allShortcodes = null; // cached list of all shortcodes for suggestions + export function fetchComposeSuggestions(token) { - return (dispatch, getState) => { - api(getState).get('/api/v1/accounts/search', { - params: { - q: token, - resolve: false, - limit: 4, - }, - }).then(response => { - dispatch(readyComposeSuggestions(token, response.data)); - }); - }; + let leading = token[0]; + + if (leading === '@') { + // handle search + return (dispatch, getState) => { + api(getState).get('/api/v1/accounts/search', { + params: { + q: token.slice(1), // remove the '@' + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(readyComposeSuggestions(token, response.data)); + }); + }; + } else if (leading === ':') { + // shortcode + if (!allShortcodes) { + allShortcodes = Object.keys(emojione.emojioneList); + // TODO when we have custom emojons merged, add them to this shortcode list + } + return (dispatch) => { + const innertxt = token.slice(1); + if (innertxt.length > 1) { // prevent searching single letter, causes lag + dispatch(readyComposeSuggestionsTxt(token, allShortcodes.filter((sc) => { + return sc.indexOf(innertxt) !== -1; + }).sort((a, b) => { + if (a.indexOf(token) === 0 && b.indexOf(token) === 0) return a.localeCompare(b); + if (a.indexOf(token) === 0) return -1; + if (b.indexOf(token) === 0) return 1; + return a.localeCompare(b); + }))); + } + }; + } else { + // hashtag + return (dispatch, getState) => { + api(getState).get('/api/v1/search', { + params: { + q: token, + resolve: true, + }, + }).then(response => { + dispatch(readyComposeSuggestionsTxt(token, response.data.hashtags.map((ht) => `#${ht}`))); + }); + }; + } }; export function readyComposeSuggestions(token, accounts) { @@ -233,9 +273,19 @@ export function readyComposeSuggestions(token, accounts) { }; }; +export function readyComposeSuggestionsTxt(token, items) { + return { + type: COMPOSE_SUGGESTIONS_READY_TXT, + token, + items, + }; +}; + export function selectComposeSuggestion(position, token, accountId) { return (dispatch, getState) => { - const completion = getState().getIn(['accounts', accountId, 'acct']); + const completion = (typeof accountId === 'string') ? + accountId.slice(1) : // text suggestion: discard the leading : or # - the replacing code replaces only what follows + getState().getIn(['accounts', accountId, 'acct']); dispatch({ type: COMPOSE_SUGGESTION_SELECT, diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 35b37600f..1c7ffe196 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -1,5 +1,6 @@ import React from 'react'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import AutosuggestShortcode from '../features/compose/components/autosuggest_shortcode'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; @@ -18,11 +19,12 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 2 || word[0] !== '@') { + if (!word || word.trim().length < 2 || ['@', ':', '#'].indexOf(word[0]) === -1) { return [null, null]; } - word = word.trim().toLowerCase().slice(1); + word = word.trim().toLowerCase(); + // was: .slice(1); - we leave the leading char there, handler can decide what to do based on it if (word.length > 0) { return [left + 1, word]; @@ -41,6 +43,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onSuggestionSelected: PropTypes.func.isRequired, onSuggestionsClearRequested: PropTypes.func.isRequired, onSuggestionsFetchRequested: PropTypes.func.isRequired, + onLocalSuggestionsFetchRequested: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, onKeyUp: PropTypes.func, onKeyDown: PropTypes.func, @@ -64,7 +67,13 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (token !== null && this.state.lastToken !== token) { this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); - this.props.onSuggestionsFetchRequested(token); + if (token[0] === ':') { + // faster debounce for shortcodes. + // hashtags have long debounce because they're fetched from server. + this.props.onLocalSuggestionsFetchRequested(token); + } else { + this.props.onSuggestionsFetchRequested(token); + } } else if (token === null) { this.setState({ lastToken: null }); this.props.onSuggestionsClearRequested(); @@ -128,7 +137,9 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } onSuggestionClick = (e) => { - const suggestion = Number(e.currentTarget.getAttribute('data-index')); + // leave suggestion string unchanged if it's a hash / shortcode suggestion. convert account number to int. + const suggestionStr = e.currentTarget.getAttribute('data-index'); + const suggestion = [':', '#'].indexOf(suggestionStr[0]) !== -1 ? suggestionStr : Number(suggestionStr); e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); this.textarea.focus(); @@ -151,6 +162,15 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + componentDidUpdate () { + if (this.refs.selected) { + if (this.refs.selected.scrollIntoViewIfNeeded) + this.refs.selected.scrollIntoViewIfNeeded(); + else + this.refs.selected.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + } + } + render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; const { suggestionsHidden, selectedSuggestion } = this.state; @@ -160,6 +180,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { style.direction = 'rtl'; } + let makeItem = (suggestion) => { + if (suggestion[0] === ':') return <AutosuggestShortcode shortcode={suggestion} />; + if (suggestion[0] === '#') return suggestion; // hashtag + + // else - accounts are always returned as IDs with no prefix + return <AutosuggestAccountContainer id={suggestion} />; + }; + return ( <div className='autosuggest-textarea'> <label> @@ -183,6 +211,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> {suggestions.map((suggestion, i) => ( <div + ref={i === selectedSuggestion ? 'selected' : null} role='button' tabIndex='0' key={suggestion} @@ -190,7 +219,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} onMouseDown={this.onSuggestionClick} > - <AutosuggestAccountContainer id={suggestion} /> + {makeItem(suggestion)} </div> ))} </div> diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js b/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js new file mode 100644 index 000000000..4a0ef96b3 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js @@ -0,0 +1,38 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import emojione from 'emojione'; + +// This is bad, but I don't know how to make it work without importing the entirety of emojione. +// taken from some old version of mastodon before they gutted emojione to "emojione_light" +const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => { + if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) { + return shortname; + } + + const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1]; + const alt = emojione.convert(unicode.toUpperCase()); + + return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`; +}); + +export default class AutosuggestShortcode extends ImmutablePureComponent { + + static propTypes = { + shortcode: PropTypes.string.isRequired, + }; + + render () { + const { shortcode } = this.props; + + let emoji = shortnameToImage(shortcode); + + return ( + <div className='autosuggest-account'> + <div className='autosuggest-account-icon' dangerouslySetInnerHTML={{ __html: emoji }} /> + {shortcode} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 000e414fe..f6b5cf0be 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -90,6 +90,10 @@ export default class ComposeForm extends ImmutablePureComponent { this.props.onFetchSuggestions(token); }, 500, { trailing: true }) + onLocalSuggestionsFetchRequested = debounce((token) => { + this.props.onFetchSuggestions(token); + }, 100, { trailing: true }) + onSuggestionSelected = (tokenStart, token, value) => { this._restoreCaret = null; this.props.onSuggestionSelected(tokenStart, token, value); @@ -186,6 +190,7 @@ export default class ComposeForm extends ImmutablePureComponent { suggestions={this.props.suggestions} onKeyDown={this.handleKeyDown} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onLocalSuggestionsFetchRequested={this.onLocalSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} onPaste={onPaste} diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index e7a3567b4..d2b29721f 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -15,6 +15,7 @@ import { COMPOSE_UPLOAD_PROGRESS, COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTIONS_READY_TXT, COMPOSE_SUGGESTION_SELECT, COMPOSE_ADVANCED_OPTIONS_CHANGE, COMPOSE_SENSITIVITY_CHANGE, @@ -263,6 +264,9 @@ export default function compose(state = initialState, action) { return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: return state.set('suggestions', ImmutableList(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTIONS_READY_TXT: + // suggestion received that is not an account - hashtag or emojo + return state.set('suggestions', ImmutableList(action.items.map(item => item))).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion); case TIMELINE_DELETE: diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index e15078f96..fb1922113 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2083,6 +2083,8 @@ .autosuggest-textarea__suggestions { display: none; position: absolute; + max-height: 300px; + overflow-y: auto; top: 100%; width: 100%; z-index: 99; diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 46dab2d0f..5fc60be17 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -6,6 +6,7 @@ = stylesheet_pack_tag 'common', media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' + = stylesheet_pack_tag 'application', integrity: true, media: 'all' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' %body.embed |