Skip to content

Commit

Permalink
Add quote feature (#249)
Browse files Browse the repository at this point in the history
* Add quote feature

* Compatible with Misskey quote

* Apply quote by parsing post body

* Add index to statuses quote_id

* Add confirmation of overwriting of input content when quoting

* quote: fix reblog availability check

* update CSS for quote status

* Modify quote related CSS (#9)

* Add feature_quote to instance API

* Fix boost target to be quoted if boosted

* Fix quote nesting at once, add quote_id

* quote: fix git HEAD mistak

* support rich content type in quote statues

Co-authored-by: Genbu Hase <[email protected]>
Co-authored-by: noellabo <[email protected]>
Co-authored-by: Allen Zhong <[email protected]>
Co-authored-by: 常盤 みどり <[email protected]>
  • Loading branch information
5 people authored Nov 3, 2020
1 parent 98fd166 commit dd16662
Show file tree
Hide file tree
Showing 43 changed files with 774 additions and 20 deletions.
4 changes: 3 additions & 1 deletion app/controllers/api/v1/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def create
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true,
content_type: status_params[:content_type], #'text/markdown'
local_only: status_params[:local_only])
local_only: status_params[:local_only],
quote_id: status_params[:quote_id].blank? ? nil : status_params[:quote_id])

render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
Expand Down Expand Up @@ -89,6 +90,7 @@ def status_params
:scheduled_at,
:content_type,
:local_only,
:quote_id,
media_ids: [],
poll: [
:multiple,
Expand Down
20 changes: 20 additions & 0 deletions app/javascript/mastodon/actions/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
Expand Down Expand Up @@ -102,6 +104,23 @@ export function cancelReplyCompose() {
};
};

export function quoteCompose(status, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_QUOTE,
status: status,
});

ensureComposeIsVisible(getState, routerHistory);
};
};

export function cancelQuoteCompose() {
return {
type: COMPOSE_QUOTE_CANCEL,
};
};

export function resetCompose() {
return {
type: COMPOSE_RESET,
Expand Down Expand Up @@ -151,6 +170,7 @@ export function submitCompose(routerHistory) {
poll: getState().getIn(['compose', 'poll'], null),
local_only: !getState().getIn(['compose', 'federation']),
content_type: getState().getIn(['compose', 'content_type']),
quote_id: getState().getIn(['compose', 'quote_from'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
Expand Down
27 changes: 27 additions & 0 deletions app/javascript/mastodon/actions/importer/normalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
normalStatus.quote = normalOldStatus.get('quote');
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
Expand All @@ -69,6 +71,31 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;

if (status.quote && status.quote.id) {
const quote_spoilerText = status.quote.spoiler_text || '';
const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');

const quote_emojiMap = makeEmojiMap(normalStatus.quote);

const quote_account_emojiMap = makeEmojiMap(status.quote.account);
const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
Array.from(docElem.querySelectorAll('span.invisible'), span => span.remove());
Array.from(docElem.querySelectorAll('p,br'), line => {
let parentNode = line.parentNode;
if (line.nextSibling) {
parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
}
});
let _contentHtml = docElem.innerHTML;
// normalStatus.quote.contentHtml = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
normalStatus.quote.contentHtml = '<p>'+emojify(_contentHtml, quote_emojiMap)+'</p>';
normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
}
}

return normalStatus;
Expand Down
25 changes: 25 additions & 0 deletions app/javascript/mastodon/actions/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';

export const REDRAFT = 'REDRAFT';

export const QUOTE_REVEAL = 'QUOTE_REVEAL';
export const QUOTE_HIDE = 'QUOTE_HIDE';

export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
Expand Down Expand Up @@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
isCollapsed,
};
}

export function hideQuote(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}

return {
type: QUOTE_HIDE,
ids,
};
};

export function revealQuote(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}

return {
type: QUOTE_REVEAL,
ids,
};
};
8 changes: 7 additions & 1 deletion app/javascript/mastodon/components/media_gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
quote: PropTypes.bool,
};

static defaultProps = {
standalone: false,
quote: false,
};

state = {
Expand Down Expand Up @@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
}

render () {
const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
const { media, intl, sensitive, height, defaultWidth, standalone, quote, autoplay } = this.props;
const { visible } = this.state;

const width = this.state.width || defaultWidth;
Expand All @@ -332,6 +334,10 @@ class MediaGallery extends React.PureComponent {
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');

if (quote) {
style.height /= 2;
}

if (standalone && this.isFullSizeEligible()) {
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
Expand Down
73 changes: 71 additions & 2 deletions app/javascript/mastodon/components/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class Status extends ImmutablePureComponent {
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
Expand All @@ -98,6 +99,7 @@ class Status extends ImmutablePureComponent {
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
usingPiP: PropTypes.bool,
contextType: PropTypes.string,
};

// Avoid checking props that are functions (and whose equality will always
Expand Down Expand Up @@ -161,6 +163,15 @@ class Status extends ImmutablePureComponent {
}
}

handleQuoteClick = () => {
if (!this.context.router) {
return;
}

const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
}

handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
Expand All @@ -177,6 +188,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}

handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this._properStatus());
};

renderLoadingMediaGallery () {
return <div className='media-gallery' style={{ height: '110px' }} />;
}
Expand Down Expand Up @@ -273,11 +288,17 @@ class Status extends ImmutablePureComponent {
this.node = c;
}

_properQuoteStatus () {
const { status } = this.props;

return status.get('quote');
}

render () {
let media = null;
let statusAvatar, prepend, rebloggedByText;
let statusAvatar, prepend, rebloggedByText, unlistedQuoteText;

const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP, contextType } = this.props;

let { status, account, ...other } = this.props;

Expand Down Expand Up @@ -452,6 +473,53 @@ class Status extends ImmutablePureComponent {

const visibilityIcon = visibilityIconInfo[status.get('visibility')];

let quote = null;
if (status.get('quote', null) !== null) {
let quote_status = status.get('quote');

let quote_media = null;
if (quote_status.get('media_attachments').size > 0) {
if (this.props.muted || quote_status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
quote_media = (
<AttachmentList
compact
media={quote_status.get('media_attachments')}
/>
);
} else {
quote_media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => <Component media={quote_status.get('media_attachments')} sensitive={quote_status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} quote />}
</Bundle>
);
}
}

if (quote_status.get('visibility') === 'unlisted' && contextType !== 'home') {
unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' });
quote = (
<div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
<div className={classNames('status__content unlisted-quote', { 'status__content--with-action': this.context.router })}>
<strong onClick={this.handleQuoteClick}>{unlistedQuoteText}</strong>
</div>
</div>
);
} else {
quote = (
<div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
<div className='status__info'>
<a onClick={this.handleAccountClick} target='_blank' data-id={quote_status.getIn(['account', 'id'])} href={quote_status.getIn(['account', 'url'])} title={quote_status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
<DisplayName account={quote_status.get('account')} />
</a>
</div>
<StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} />
{quote_media}
</div>
);
}
}

return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
Expand All @@ -477,6 +545,7 @@ class Status extends ImmutablePureComponent {
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />

{media}
{quote}

<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
</div>
Expand Down
7 changes: 7 additions & 0 deletions app/javascript/mastodon/components/status_action_bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const messages = defineMessages({
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
Expand Down Expand Up @@ -62,6 +63,7 @@ class StatusActionBar extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
Expand Down Expand Up @@ -130,6 +132,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status);
}

handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history);
}

handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
Expand Down Expand Up @@ -325,6 +331,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />

<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
{shareButton}

<div className='status__action-bar-dropdown'>
Expand Down
29 changes: 29 additions & 0 deletions app/javascript/mastodon/containers/status_container.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Status from '../components/status';
import { makeGetStatus } from '../selectors';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../actions/compose';
Expand All @@ -24,6 +25,8 @@ import {
hideStatus,
revealStatus,
toggleStatusCollapse,
hideQuote,
revealQuote,
} from '../actions/statuses';
import {
unmuteAccount,
Expand All @@ -49,6 +52,8 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});

Expand Down Expand Up @@ -97,6 +102,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},

onQuote (status, router) {
dispatch((_, getState) => {
let state = getState();

if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: () => dispatch(quoteCompose(status, router)),
}));
} else {
dispatch(quoteCompose(status, router));
}
});
},

onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
Expand Down Expand Up @@ -208,6 +229,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onUnblockDomain (domain) {
dispatch(unblockDomain(domain));
},

onQuoteToggleHidden (status) {
if (status.get('quote_hidden')) {
dispatch(revealQuote(status.get('id')));
} else {
dispatch(hideQuote(status.get('id')));
}
},

deployPictureInPicture (status, type, mediaProps) {
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
Expand Down
Loading

0 comments on commit dd16662

Please sign in to comment.