remake quote feature
This commit is contained in:
parent
c77947f15a
commit
27da15b734
37 changed files with 588 additions and 50 deletions
|
@ -46,7 +46,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
application: doorkeeper_token.application,
|
||||
poll: status_params[:poll],
|
||||
idempotency: request.headers['Idempotency-Key'],
|
||||
with_rate_limit: true)
|
||||
with_rate_limit: true,
|
||||
quote_id: status_params[:quote_id].presence)
|
||||
|
||||
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||
end
|
||||
|
@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:spoiler_text,
|
||||
:visibility,
|
||||
:scheduled_at,
|
||||
:quote_id,
|
||||
media_ids: [],
|
||||
poll: [
|
||||
:multiple,
|
||||
|
|
|
@ -108,16 +108,14 @@ export function cancelReplyCompose() {
|
|||
};
|
||||
};
|
||||
|
||||
export function quoteCompose(status, router) {
|
||||
export function quoteCompose(status, routerHistory) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_QUOTE,
|
||||
status: status,
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -157,22 +155,13 @@ export function directCompose(account, routerHistory) {
|
|||
|
||||
export function submitCompose(routerHistory) {
|
||||
return function (dispatch, getState) {
|
||||
let status = getState().getIn(['compose', 'text'], '');
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const quoteId = getState().getIn(['compose', 'quote_from'], null);
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (quoteId) {
|
||||
status = [
|
||||
status,
|
||||
"~~~~~~~~~~",
|
||||
`[${quoteId}][${getState().getIn(['compose', 'quote_from_uri'], null)}]`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
dispatch(submitComposeRequest());
|
||||
|
||||
api(getState).post('/api/v1/statuses', {
|
||||
|
@ -183,6 +172,7 @@ export function submitCompose(routerHistory) {
|
|||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
quote_id: getState().getIn(['compose', 'quote_from'], null),
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
|
|
@ -62,6 +62,8 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
|
||||
normalStatus.hidden = normalOldStatus.get('hidden');
|
||||
normalStatus.quote = normalOldStatus.get('quote');
|
||||
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
|
||||
} else {
|
||||
// If the status has a CW but no contents, treat the CW as if it were the
|
||||
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
||||
|
@ -78,6 +80,30 @@ 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.textContent;
|
||||
normalStatus.quote.contentHtml = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
|
||||
normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
|
||||
normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
|
||||
}
|
||||
}
|
||||
|
||||
return normalStatus;
|
||||
|
|
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 = {
|
||||
|
@ -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, autoplay, quote } = this.props;
|
||||
const { visible } = this.state;
|
||||
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
@ -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) {
|
||||
style.height /= 2;
|
||||
}
|
||||
|
||||
if (standalone && this.isFullSizeEligible()) {
|
||||
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
|
|
|
@ -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,
|
||||
|
@ -101,6 +102,7 @@ class Status extends ImmutablePureComponent {
|
|||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
contextType: PropTypes.string,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
@ -116,6 +118,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
state = {
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||
statusId: undefined,
|
||||
};
|
||||
|
||||
|
@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent {
|
|||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
return {
|
||||
showMedia: defaultMediaVisibility(nextProps.status),
|
||||
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
|
||||
statusId: nextProps.status.get('id'),
|
||||
};
|
||||
} else {
|
||||
|
@ -134,6 +138,10 @@ class Status extends ImmutablePureComponent {
|
|||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleToggleQuoteMediaVisibility = () => {
|
||||
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
|
@ -164,6 +172,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');
|
||||
|
@ -180,6 +197,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' }} />;
|
||||
}
|
||||
|
@ -279,11 +300,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, pictureInPicture } = this.props;
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
|
@ -459,6 +486,106 @@ 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_media = (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={quote_status.get('media_attachments')}
|
||||
/>
|
||||
);
|
||||
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||
|
||||
quote_media = (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
peaks={[0]}
|
||||
height={70}
|
||||
quote
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||
|
||||
quote_media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
preview={attachment.get('preview_url')}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
inline
|
||||
sensitive={quote_status.get('sensitive')}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
visible={this.state.showQuoteMedia}
|
||||
onToggleVisibility={this.handleToggleQuoteMediaVisibility}
|
||||
quote
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} 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}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showQuoteMedia}
|
||||
onToggleVisibility={this.handleToggleQuoteMediaVisibility}
|
||||
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 })}>
|
||||
<button onClick={this.handleQuoteClick}>{unlistedQuoteText}</button>
|
||||
</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 />
|
||||
{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}>
|
||||
|
@ -483,6 +610,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
|
||||
|
||||
{quote}
|
||||
{media}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
|
|
|
@ -24,6 +24,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
onClick: PropTypes.func,
|
||||
collapsable: PropTypes.bool,
|
||||
onCollapsedToggle: PropTypes.func,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -38,8 +39,6 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
|
||||
const links = node.querySelectorAll('a');
|
||||
const QuoteUrlFormat = /(?:https?|ftp):\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+\/users\/[\w-_]+(\/statuses\/\w+)/;
|
||||
const quote = node.innerText.match(new RegExp(`\\[(\\w+)\\]\\[${QuoteUrlFormat.source}\\]`));
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
|
@ -48,12 +47,6 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
link.classList.add('status-link');
|
||||
|
||||
if (quote) {
|
||||
if (link.href.match(QuoteUrlFormat)) {
|
||||
link.addEventListener('click', this.onQuoteClick.bind(this, quote[1]), false);
|
||||
}
|
||||
}
|
||||
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
|
@ -185,7 +178,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
const { status, quote } = this.props;
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
|
@ -238,7 +231,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
{!quote && !hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
||||
{renderViewThread && showThreadButton}
|
||||
</div>
|
||||
|
@ -248,7 +241,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
||||
|
||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
{!quote && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
||||
{renderViewThread && showThreadButton}
|
||||
</div>,
|
||||
|
@ -264,7 +257,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
||||
|
||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
{!quote && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
||||
{renderViewThread && showThreadButton}
|
||||
</div>
|
||||
|
|
|
@ -25,6 +25,8 @@ import {
|
|||
hideStatus,
|
||||
revealStatus,
|
||||
toggleStatusCollapse,
|
||||
hideQuote,
|
||||
revealQuote,
|
||||
} from '../actions/statuses';
|
||||
import {
|
||||
unmuteAccount,
|
||||
|
@ -220,6 +222,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
|
||||
},
|
||||
|
||||
onQuoteToggleHidden (status) {
|
||||
if (status.get('quote_hidden')) {
|
||||
dispatch(revealQuote(status.get('id')));
|
||||
} else {
|
||||
dispatch(hideQuote(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
||||
|
|
|
@ -7,13 +7,14 @@ import DisplayName from '../../../components/display_name';
|
|||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { isRtl } from '../../../rtl';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class QuoteIndicator extends ImmutablePureComponent {
|
||||
export default @injectIntl
|
||||
class QuoteIndicator extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
|
@ -30,7 +31,7 @@ export default class QuoteIndicator extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
@ -60,6 +61,13 @@ export default class QuoteIndicator extends ImmutablePureComponent {
|
|||
</div>
|
||||
|
||||
<div className='quote-indicator__content' style={style} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{status.get('media_attachments').size > 0 && (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={status.get('media_attachments')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
|
|||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
status: getStatus(state, state.getIn(['compose', 'quote_from'])),
|
||||
status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
|
|
|
@ -283,7 +283,7 @@ class ActionBar extends React.PureComponent {
|
|||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={reblog_disabled} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
|
||||
{shareButton}
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
|
|
|
@ -72,6 +72,7 @@ export default class Card extends React.PureComponent {
|
|||
defaultWidth: PropTypes.number,
|
||||
cacheWidth: PropTypes.func,
|
||||
sensitive: PropTypes.bool,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -188,7 +189,7 @@ export default class Card extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { card, maxDescription, compact } = this.props;
|
||||
const { card, maxDescription, compact, quote } = this.props;
|
||||
const { width, embedded, revealed } = this.state;
|
||||
|
||||
if (card === null) {
|
||||
|
@ -201,7 +202,11 @@ export default class Card extends React.PureComponent {
|
|||
const className = classnames('status-card', { horizontal, compact, interactive });
|
||||
const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
|
||||
const ratio = card.get('width') / card.get('height');
|
||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
||||
if (quote && height) {
|
||||
height /= 2;
|
||||
}
|
||||
|
||||
const description = (
|
||||
<div className='status-card__content'>
|
||||
|
|
|
@ -46,6 +46,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
available: PropTypes.bool,
|
||||
}),
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
onQuoteToggleHidden: PropTypes.func.isRequired,
|
||||
showQuoteMedia: PropTypes.bool,
|
||||
onToggleQuoteMediaVisibility: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -102,6 +105,19 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
}
|
||||
|
||||
handleExpandedQuoteToggle = () => {
|
||||
this.props.onQuoteToggleHidden(this.props.status);
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = this.props;
|
||||
this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
|
||||
}
|
||||
|
||||
render () {
|
||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
|
@ -121,6 +137,73 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
outerStyle.height = `${this.state.height}px`;
|
||||
}
|
||||
|
||||
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 (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||
|
||||
quote_media = (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={60}
|
||||
preload
|
||||
/>
|
||||
);
|
||||
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||
|
||||
quote_media = (
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
width={300}
|
||||
height={150}
|
||||
inline
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
sensitive={quote_status.get('sensitive')}
|
||||
visible={this.props.showQuoteMedia}
|
||||
onToggleVisibility={this.props.onToggleQuoteMediaVisibility}
|
||||
quote
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
quote_media = (
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={quote_status.get('sensitive')}
|
||||
media={quote_status.get('media_attachments')}
|
||||
height={300}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
visible={this.props.showQuoteMedia}
|
||||
onToggleVisibility={this.props.onToggleQuoteMediaVisibility}
|
||||
quote
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
quote = (
|
||||
<div className='quote-status'>
|
||||
<a href={quote_status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
|
||||
<DisplayName account={quote_status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
|
||||
<StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} quote />
|
||||
{quote_media}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
|
@ -247,6 +330,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
|
||||
|
||||
{quote}
|
||||
{media}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
|
|
|
@ -32,6 +32,8 @@ import {
|
|||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
hideQuote,
|
||||
revealQuote,
|
||||
} from '../../actions/statuses';
|
||||
import {
|
||||
unblockAccount,
|
||||
|
@ -181,6 +183,7 @@ class Status extends ImmutablePureComponent {
|
|||
state = {
|
||||
fullscreen: false,
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||
loadedStatusId: undefined,
|
||||
};
|
||||
|
||||
|
@ -199,7 +202,8 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
|
||||
this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
|
||||
this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id'),
|
||||
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)) });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -207,6 +211,10 @@ class Status extends ImmutablePureComponent {
|
|||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleToggleQuoteMediaVisibility = () => {
|
||||
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||
}
|
||||
|
||||
handleFavouriteClick = (status) => {
|
||||
if (status.get('favourited')) {
|
||||
this.props.dispatch(unfavourite(status));
|
||||
|
@ -328,6 +336,14 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleQuoteToggleHidden = (status) => {
|
||||
if (status.get('quote_hidden')) {
|
||||
this.props.dispatch(revealQuote(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(hideQuote(status.get('id')));
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleAll = () => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||
|
@ -562,6 +578,9 @@ class Status extends ImmutablePureComponent {
|
|||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
onQuoteToggleHidden={this.handleQuoteToggleHidden}
|
||||
showQuoteMedia={this.state.showQuoteMedia}
|
||||
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
|
|
|
@ -122,6 +122,7 @@ class Video extends React.PureComponent {
|
|||
volume: PropTypes.number,
|
||||
muted: PropTypes.bool,
|
||||
componetIndex: PropTypes.number,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -523,7 +524,7 @@ class Video extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash } = this.props;
|
||||
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, quote } = this.props;
|
||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const playerStyle = {};
|
||||
|
@ -537,6 +538,11 @@ class Video extends React.PureComponent {
|
|||
playerStyle.height = height;
|
||||
}
|
||||
|
||||
if (quote && height) {
|
||||
height /= 2;
|
||||
playerStyle.height = height;
|
||||
}
|
||||
|
||||
let preload;
|
||||
|
||||
if (this.props.currentTime || fullscreen || dragging) {
|
||||
|
|
|
@ -453,6 +453,10 @@
|
|||
"defaultMessage": "Quote",
|
||||
"id": "status.quote"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Unlisted quote",
|
||||
"id": "status.unlisted_quote"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Favourite",
|
||||
"id": "status.favourite"
|
||||
|
|
|
@ -402,7 +402,8 @@
|
|||
"status.pin": "プロフィールに固定表示",
|
||||
"status.pinned": "固定された投稿",
|
||||
"status.read_more": "もっと見る",
|
||||
"status.quote": "引用ブースト",
|
||||
"status.quote": "引用",
|
||||
"status.unlisted_quote": "未収載の引用",
|
||||
"status.reblog": "ブースト",
|
||||
"status.reblog_private": "ブースト",
|
||||
"status.reblogged_by": "{name}さんがブースト",
|
||||
|
|
|
@ -65,7 +65,7 @@ const initialState = ImmutableMap({
|
|||
preselectDate: null,
|
||||
in_reply_to: null,
|
||||
quote_from: null,
|
||||
quote_from_uri: null,
|
||||
quote_from_url: null,
|
||||
is_composing: false,
|
||||
is_submitting: false,
|
||||
is_changing_upload: false,
|
||||
|
@ -262,6 +262,17 @@ const updateSuggestionTags = (state, token) => {
|
|||
});
|
||||
};
|
||||
|
||||
const rejectQuoteAltText = html => {
|
||||
const fragment = domParser.parseFromString(html, 'text/html').documentElement;
|
||||
|
||||
const quote_inline = fragment.querySelector('span.quote-inline');
|
||||
if (quote_inline) {
|
||||
quote_inline.remove();
|
||||
}
|
||||
|
||||
return fragment.innerHTML;
|
||||
};
|
||||
|
||||
export default function compose(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
|
@ -308,7 +319,7 @@ export default function compose(state = initialState, action) {
|
|||
return state.withMutations(map => {
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('quote_from', null);
|
||||
map.set('quote_from_uri', null);
|
||||
map.set('quote_from_url', null);
|
||||
map.set('text', statusToTextMentions(state, action.status));
|
||||
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
||||
map.set('focusDate', new Date());
|
||||
|
@ -328,12 +339,20 @@ export default function compose(state = initialState, action) {
|
|||
return state.withMutations(map => {
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', action.status.get('id'));
|
||||
map.set('quote_from_uri', action.status.get('uri'));
|
||||
map.set('quote_from_url', action.status.get('url'));
|
||||
map.set('text', '');
|
||||
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
||||
map.set('focusDate', new Date());
|
||||
map.set('preselectDate', new Date());
|
||||
map.set('idempotencyKey', uuid());
|
||||
|
||||
if (action.status.get('spoiler_text').length > 0) {
|
||||
map.set('spoiler', true);
|
||||
map.set('spoiler_text', action.status.get('spoiler_text'));
|
||||
} else {
|
||||
map.set('spoiler', false);
|
||||
map.set('spoiler_text', '');
|
||||
}
|
||||
});
|
||||
case COMPOSE_REPLY_CANCEL:
|
||||
case COMPOSE_QUOTE_CANCEL:
|
||||
|
@ -341,7 +360,7 @@ export default function compose(state = initialState, action) {
|
|||
return state.withMutations(map => {
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', null);
|
||||
map.set('quote_from_uri', null);
|
||||
map.set('quote_from_url', null);
|
||||
map.set('text', '');
|
||||
map.set('spoiler', false);
|
||||
map.set('spoiler_text', '');
|
||||
|
@ -444,8 +463,10 @@ export default function compose(state = initialState, action) {
|
|||
}));
|
||||
case REDRAFT:
|
||||
return state.withMutations(map => {
|
||||
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
|
||||
map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
|
||||
map.set('in_reply_to', action.status.get('in_reply_to_id'));
|
||||
map.set('quote_from', action.status.getIn(['quote', 'id']));
|
||||
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
|
||||
map.set('privacy', action.status.get('visibility'));
|
||||
map.set('media_attachments', action.status.get('media_attachments'));
|
||||
map.set('focusDate', new Date());
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
COMPOSE_MENTION,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_DIRECT,
|
||||
COMPOSE_QUOTE,
|
||||
} from '../actions/compose';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
|
@ -36,6 +37,7 @@ export default function search(state = initialState, action) {
|
|||
case COMPOSE_REPLY:
|
||||
case COMPOSE_MENTION:
|
||||
case COMPOSE_DIRECT:
|
||||
case COMPOSE_QUOTE:
|
||||
return state.set('hidden', true);
|
||||
case SEARCH_FETCH_SUCCESS:
|
||||
return state.set('results', ImmutableMap({
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
STATUS_REVEAL,
|
||||
STATUS_HIDE,
|
||||
STATUS_COLLAPSE,
|
||||
QUOTE_REVEAL,
|
||||
QUOTE_HIDE,
|
||||
} from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||
|
@ -75,6 +77,14 @@ export default function statuses(state = initialState, action) {
|
|||
});
|
||||
case STATUS_COLLAPSE:
|
||||
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
|
||||
case QUOTE_REVEAL:
|
||||
return state.withMutations(map => {
|
||||
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
|
||||
});
|
||||
case QUOTE_HIDE:
|
||||
return state.withMutations(map => {
|
||||
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
|
||||
});
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatus(state, action.id, action.references);
|
||||
default:
|
||||
|
|
|
@ -291,6 +291,29 @@ function main() {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
delegate(document, '.quote-status', 'click', ({ target }) => {
|
||||
if (target.closest('.status__content__spoiler-link') ||
|
||||
target.closest('.media-gallery') ||
|
||||
target.closest('.video-player') ||
|
||||
target.closest('.audio-player')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let url = target.closest('.quote-status').getAttribute('dataurl');
|
||||
if (target.closest('.status__display-name')) {
|
||||
url = target.closest('.status__display-name').getAttribute('href');
|
||||
} else if (target.closest('.status-card')) {
|
||||
url = target.closest('.status-card').getAttribute('href');
|
||||
}
|
||||
|
||||
if (window.location.hostname === url.split('/')[2].split(':')[0]) {
|
||||
window.location.href = url;
|
||||
} else {
|
||||
window.open(url, 'blank');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
loadPolyfills()
|
||||
|
|
|
@ -1009,6 +1009,70 @@
|
|||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
.quote-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quote-status {
|
||||
border: solid 1px $ui-base-lighter-color;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
|
||||
& > .unlisted-quote {
|
||||
color: $dark-text-color;
|
||||
font-weight: 500;
|
||||
|
||||
& > button {
|
||||
color: $dark-text-color;
|
||||
font-size: 100%;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status__avatar,
|
||||
.detailed-status__display-avatar {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
position: absolute;
|
||||
top: 5px !important;
|
||||
left: 5px !important;
|
||||
cursor: pointer;
|
||||
|
||||
& > div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name__account {
|
||||
color: $ui-base-lighter-color;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detailed-status__display-name {
|
||||
margin-bottom: 0px;
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.muted .quote-status .display-name {
|
||||
color: $ui-base-lighter-color;
|
||||
}
|
||||
|
||||
.status__prepend-icon-wrapper {
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
|
|
|
@ -63,6 +63,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
.status.quote-status {
|
||||
border: solid 1px $ui-base-lighter-color;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
margin-top: 15px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
.status__avatar {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
|
||||
& > div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 740px) {
|
||||
.detailed-status,
|
||||
.status,
|
||||
|
|
|
@ -112,6 +112,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
conversation: conversation_from_uri(@object['conversation']),
|
||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||
poll: process_poll,
|
||||
quote: quote_from_url(@object['quoteUrl']),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -504,4 +505,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
poll.reload
|
||||
retry
|
||||
end
|
||||
|
||||
def quote_from_url(url)
|
||||
return nil if url.nil?
|
||||
|
||||
quote = ResolveURLService.new.call(url)
|
||||
status_from_uri(quote.uri) if quote
|
||||
end
|
||||
end
|
||||
|
|
|
@ -24,6 +24,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
|
|
|
@ -38,11 +38,24 @@ class Formatter
|
|||
html = encode_and_link_urls(html, linkable_accounts)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html = simple_format(html, {}, sanitize: false)
|
||||
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
|
||||
html = html.delete("\n")
|
||||
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def format_in_quote(status, **options)
|
||||
html = format(status)
|
||||
return '' if html.empty?
|
||||
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
|
||||
doc.search('span.invisible').remove
|
||||
html = doc.css('body')[0].inner_html
|
||||
html.sub!(/^<p>(.+)<\/p>$/, '\1')
|
||||
html = Sanitize.clean(html).delete("\n").truncate(150)
|
||||
html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
|
||||
html.html_safe
|
||||
end
|
||||
|
||||
def reformat(html)
|
||||
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
||||
rescue ArgumentError
|
||||
|
@ -191,6 +204,12 @@ class Formatter
|
|||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
|
||||
def quotify(html, status)
|
||||
url = ActivityPub::TagManager.instance.url_for(status.quote)
|
||||
link = encode_and_link_urls(url)
|
||||
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{link}</span>\\1")
|
||||
end
|
||||
|
||||
def rewrite(text, entities)
|
||||
text = text.to_s
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
# in_reply_to_account_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# deleted_at :datetime
|
||||
# quote_id :bigint(8)
|
||||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
|
@ -55,6 +56,7 @@ class Status < ApplicationRecord
|
|||
|
||||
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
|
||||
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
|
||||
belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
|
||||
|
||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||
has_many :bookmarks, inverse_of: :status, dependent: :destroy
|
||||
|
@ -63,6 +65,7 @@ class Status < ApplicationRecord
|
|||
has_many :mentions, dependent: :destroy, inverse_of: :status
|
||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
|
||||
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
|
@ -169,6 +172,10 @@ class Status < ApplicationRecord
|
|||
!reblog_of_id.nil?
|
||||
end
|
||||
|
||||
def quote?
|
||||
!quote_id.nil? && quote
|
||||
end
|
||||
|
||||
def within_realtime_window?
|
||||
created_at >= REAL_TIME_WINDOW.ago
|
||||
end
|
||||
|
@ -229,7 +236,7 @@ class Status < ApplicationRecord
|
|||
fields = [spoiler_text, text]
|
||||
fields += preloadable_poll.options unless preloadable_poll.nil?
|
||||
|
||||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
|
||||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
|
||||
end
|
||||
|
||||
def replies_count
|
||||
|
|
|
@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
:in_reply_to, :published, :url,
|
||||
:attributed_to, :to, :cc, :sensitive,
|
||||
:atom_uri, :in_reply_to_atom_uri,
|
||||
:conversation
|
||||
:conversation, :quote_url
|
||||
|
||||
attribute :content
|
||||
attribute :content_map, if: :language?
|
||||
|
@ -125,6 +125,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
end
|
||||
end
|
||||
|
||||
def quote_url
|
||||
object.quote? ? ActivityPub::TagManager.instance.uri_for(object.quote) : nil
|
||||
end
|
||||
|
||||
def local?
|
||||
object.account.local?
|
||||
end
|
||||
|
|
|
@ -18,6 +18,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
belongs_to :reblog, serializer: REST::StatusSerializer
|
||||
belongs_to :application, if: :show_application?
|
||||
belongs_to :account, serializer: REST::AccountSerializer
|
||||
belongs_to :quote, serializer: REST::StatusSerializer
|
||||
|
||||
has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
|
||||
has_many :ordered_mentions, key: :mentions
|
||||
|
|
|
@ -67,7 +67,7 @@ class FetchLinkCardService < BaseService
|
|||
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
|
||||
else
|
||||
html = Nokogiri::HTML(@status.text)
|
||||
links = html.css('a')
|
||||
links = html.css(':not(.quote-inline) > a')
|
||||
urls = links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
|
||||
end
|
||||
|
||||
|
@ -76,7 +76,7 @@ class FetchLinkCardService < BaseService
|
|||
|
||||
def bad_url?(uri)
|
||||
# Avoid local instance URLs and invalid URLs
|
||||
uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && uri.to_s !~ %r(/users/[\w_-]+/statuses/\w+)) || !%w(http https).include?(uri.scheme)
|
||||
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
|
||||
end
|
||||
|
||||
# rubocop:disable Naming/MethodParameterName
|
||||
|
@ -132,7 +132,7 @@ class FetchLinkCardService < BaseService
|
|||
# Most providers rely on <script> tags, which is a no-no
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
@card.save_with_optional_image!
|
||||
end
|
||||
|
||||
|
|
|
@ -164,6 +164,7 @@ class PostStatusService < BaseService
|
|||
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
|
||||
application: @options[:application],
|
||||
rate_limit: @options[:with_rate_limit],
|
||||
quote_id: @options[:quote_id],
|
||||
}.compact
|
||||
end
|
||||
|
||||
|
|
|
@ -25,6 +25,9 @@
|
|||
- if status.preloadable_poll
|
||||
= render_poll_component(status)
|
||||
|
||||
- if status.quote?
|
||||
= render partial: "quote_status", locals: {status: status.quote}
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
= render_video_component(status, width: 670, height: 380, detailed: true)
|
||||
|
|
35
app/views/statuses/_quote_status.html.haml
Normal file
35
app/views/statuses/_quote_status.html.haml
Normal file
|
@ -0,0 +1,35 @@
|
|||
.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do
|
||||
.status__avatar
|
||||
%div
|
||||
= image_tag status.account.avatar_static_url, width: 18, height: 18, alt: '', class: 'u-photo account__avatar'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true)
|
||||
|
||||
%span.display-name__account
|
||||
= acct(status.account)
|
||||
= fa_icon('lock') if status.account.locked?
|
||||
|
||||
.status__content.emojify<
|
||||
- if status.spoiler_text?
|
||||
%p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }<
|
||||
%span.p-summary> #{Formatter.instance.format_spoiler(status)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }
|
||||
= Formatter.instance.format_in_quote(status, custom_emojify: true)
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description, quote: true do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), height: 60, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.preview_card
|
||||
= react_component :card, maxDescription: 10, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true
|
|
@ -34,6 +34,9 @@
|
|||
- if status.preloadable_poll
|
||||
= render_poll_component(status)
|
||||
|
||||
- if status.quote?
|
||||
= render partial: "statuses/quote_status", locals: {status: status.quote}
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
= render_video_component(status, width: 610, height: 343)
|
||||
|
|
5
db/migrate/20180419235016_add_quote_id_to_statuses.rb
Normal file
5
db/migrate/20180419235016_add_quote_id_to_statuses.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddQuoteIdToStatuses < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :statuses, :quote_id, :bigint, null: true, default: nil
|
||||
end
|
||||
end
|
|
@ -843,6 +843,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
t.bigint "in_reply_to_account_id"
|
||||
t.bigint "poll_id"
|
||||
t.datetime "deleted_at"
|
||||
t.bigint "quote_id"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
||||
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
||||
|
|
|
@ -31,6 +31,7 @@ class Sanitize
|
|||
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
|
||||
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
|
||||
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
|
||||
next true if /^quote-inline$/.match?(e) # quote inline classes
|
||||
end
|
||||
|
||||
node['class'] = class_list.join(' ')
|
||||
|
|
Loading…
Reference in a new issue