Change embedded posts to use web UI (#31766)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
f2a92c2d22
commit
3d46f47817
115 changed files with 710 additions and 1928 deletions
|
@ -19,14 +19,6 @@ module AccountsHelper
|
|||
end
|
||||
end
|
||||
|
||||
def account_action_button(account)
|
||||
return if account.memorial? || account.moved?
|
||||
|
||||
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
|
||||
safe_join([logo_as_symbol, t('accounts.follow')])
|
||||
end
|
||||
end
|
||||
|
||||
def account_formatted_stat(value)
|
||||
number_to_human(value, precision: 3, strip_insignificant_zeros: true)
|
||||
end
|
||||
|
|
|
@ -57,26 +57,6 @@ module MediaComponentHelper
|
|||
end
|
||||
end
|
||||
|
||||
def render_card_component(status, **options)
|
||||
component_params = {
|
||||
sensitive: sensitive_viewer?(status, current_account),
|
||||
card: serialize_status_card(status).as_json,
|
||||
}.merge(**options)
|
||||
|
||||
react_component :card, component_params
|
||||
end
|
||||
|
||||
def render_poll_component(status, **options)
|
||||
component_params = {
|
||||
disabled: true,
|
||||
poll: serialize_status_poll(status).as_json,
|
||||
}.merge(**options)
|
||||
|
||||
react_component :poll, component_params do
|
||||
render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_media_attachment(attachment)
|
||||
|
@ -86,22 +66,6 @@ module MediaComponentHelper
|
|||
)
|
||||
end
|
||||
|
||||
def serialize_status_card(status)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
status.preview_card,
|
||||
serializer: REST::PreviewCardSerializer
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_status_poll(status)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
status.preloadable_poll,
|
||||
serializer: REST::PollSerializer,
|
||||
scope: current_user,
|
||||
scope_name: :current_user
|
||||
)
|
||||
end
|
||||
|
||||
def sensitive_viewer?(status, account)
|
||||
if !account.nil? && account.id == status.account_id
|
||||
status.sensitive
|
||||
|
|
74
app/javascript/entrypoints/embed.tsx
Normal file
74
app/javascript/entrypoints/embed.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import './public-path';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal';
|
||||
|
||||
import { start } from '../mastodon/common';
|
||||
import { Status } from '../mastodon/features/standalone/status';
|
||||
import { loadPolyfills } from '../mastodon/polyfills';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
start();
|
||||
|
||||
function loaded() {
|
||||
const mountNode = document.getElementById('mastodon-status');
|
||||
|
||||
if (mountNode) {
|
||||
const attr = mountNode.getAttribute('data-props');
|
||||
|
||||
if (!attr) return;
|
||||
|
||||
const props = JSON.parse(attr) as { id: string; locale: string };
|
||||
const root = createRoot(mountNode);
|
||||
|
||||
root.render(<Status {...props} />);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
loadPolyfills()
|
||||
.then(main)
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
interface SetHeightMessage {
|
||||
type: 'setHeight';
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
data.type === 'setHeight'
|
||||
)
|
||||
return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||
|
||||
const data = e.data;
|
||||
|
||||
// We use a timeout to allow for the React page to render before calculating the height
|
||||
afterInitialRender(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'setHeight',
|
||||
id: data.id,
|
||||
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -37,43 +37,6 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
interface SetHeightMessage {
|
||||
type: 'setHeight';
|
||||
id: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
data.type === 'setHeight'
|
||||
)
|
||||
return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
|
||||
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
|
||||
|
||||
const data = e.data;
|
||||
|
||||
ready(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'setHeight',
|
||||
id: data.id,
|
||||
height: document.getElementsByTagName('html')[0]?.scrollHeight,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}).catch((e: unknown) => {
|
||||
console.error('Error in setHeightMessage postMessage', e);
|
||||
});
|
||||
});
|
||||
|
||||
function loaded() {
|
||||
const { messages: localeData } = getLocale();
|
||||
|
||||
|
|
32
app/javascript/hooks/useRenderSignal.ts
Normal file
32
app/javascript/hooks/useRenderSignal.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
// This hook allows a component to signal that it's done rendering in a way that
|
||||
// can be used by e.g. our embed code to determine correct iframe height
|
||||
|
||||
let renderSignalReceived = false;
|
||||
|
||||
type Callback = () => void;
|
||||
|
||||
let onInitialRender: Callback;
|
||||
|
||||
export const afterInitialRender = (callback: Callback) => {
|
||||
if (renderSignalReceived) {
|
||||
callback();
|
||||
} else {
|
||||
onInitialRender = callback;
|
||||
}
|
||||
};
|
||||
|
||||
export const useRenderSignal = () => {
|
||||
return () => {
|
||||
if (renderSignalReceived) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderSignalReceived = true;
|
||||
|
||||
if (typeof onInitialRender !== 'undefined') {
|
||||
window.requestAnimationFrame(() => {
|
||||
onInitialRender();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
|
@ -49,11 +49,13 @@ export function fetchStatusRequest(id, skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatus(id, forceFetch = false) {
|
||||
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
dispatch(fetchContext(id));
|
||||
if (alsoFetchContext) {
|
||||
dispatch(fetchContext(id));
|
||||
}
|
||||
|
||||
if (skipLoading) {
|
||||
return;
|
||||
|
|
|
@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const IconLogo: React.FC = () => (
|
||||
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
||||
<title>Mastodon</title>
|
||||
<use xlinkHref='#logo-symbol-icon' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SymbolLogo: React.FC = () => (
|
||||
<img src={logo} alt='Mastodon' className='logo logo--icon' />
|
||||
);
|
||||
|
|
|
@ -2,14 +2,12 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconLogo } from 'mastodon/components/logo';
|
||||
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
|
||||
|
||||
export const MoreFromAuthor = ({ accountId }) => (
|
||||
<div className='more-from-author'>
|
||||
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
||||
<use xlinkHref='#logo-symbol-icon' />
|
||||
</svg>
|
||||
|
||||
<IconLogo />
|
||||
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
|
||||
</div>
|
||||
);
|
||||
|
|
87
app/javascript/mastodon/features/standalone/status/index.tsx
Normal file
87
app/javascript/mastodon/features/standalone/status/index.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-explicit-any,
|
||||
@typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { useRenderSignal } from 'mastodon/../hooks/useRenderSignal';
|
||||
import { fetchStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||
import { hydrateStore } from 'mastodon/actions/store';
|
||||
import { Router } from 'mastodon/components/router';
|
||||
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
||||
import initialState from 'mastodon/initial_state';
|
||||
import { IntlProvider } from 'mastodon/locales';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
|
||||
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
|
||||
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
|
||||
arg0: any,
|
||||
arg1: any,
|
||||
) => any;
|
||||
|
||||
const Embed: React.FC<{ id: string }> = ({ id }) => {
|
||||
const status = useAppSelector((state) => getStatus(state, { id }));
|
||||
const pictureInPicture = useAppSelector((state) =>
|
||||
getPictureInPicture(state, { id }),
|
||||
);
|
||||
const domain = useAppSelector((state) => state.meta.get('domain'));
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatchRenderSignal = useRenderSignal();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStatus(id, false, false));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleToggleHidden = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
// This allows us to calculate the correct page height for embeds
|
||||
if (status) {
|
||||
dispatchRenderSignal();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const permalink = status?.get('url') as string;
|
||||
|
||||
return (
|
||||
<div className='embed'>
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
domain={domain}
|
||||
pictureInPicture={pictureInPicture}
|
||||
onToggleHidden={handleToggleHidden}
|
||||
withLogo
|
||||
/>
|
||||
|
||||
<a
|
||||
className='embed__overlay'
|
||||
href={permalink}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
aria-label=''
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Status: React.FC<{ id: string }> = ({ id }) => {
|
||||
useEffect(() => {
|
||||
if (initialState) {
|
||||
store.dispatch(hydrateStore(initialState));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IntlProvider>
|
||||
<Provider store={store}>
|
||||
<Router>
|
||||
<Embed id={id} />
|
||||
</Router>
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
|
@ -1,322 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import Video from '../../video';
|
||||
|
||||
import Card from './card';
|
||||
|
||||
class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
onToggleHidden: PropTypes.func.isRequired,
|
||||
onTranslate: PropTypes.func.isRequired,
|
||||
measureHeight: PropTypes.bool,
|
||||
onHeightChange: PropTypes.func,
|
||||
domain: PropTypes.string.isRequired,
|
||||
compact: PropTypes.bool,
|
||||
showMedia: PropTypes.bool,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
state = {
|
||||
height: null,
|
||||
};
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.props.history) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
handleOpenVideo = (options) => {
|
||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
|
||||
};
|
||||
|
||||
handleExpandedToggle = () => {
|
||||
this.props.onToggleHidden(this.props.status);
|
||||
};
|
||||
|
||||
_measureHeight (heightJustChanged) {
|
||||
if (this.props.measureHeight && this.node) {
|
||||
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
|
||||
|
||||
if (this.props.onHeightChange && heightJustChanged) {
|
||||
this.props.onHeightChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
this._measureHeight();
|
||||
};
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
this._measureHeight(prevState.height !== this.state.height);
|
||||
}
|
||||
|
||||
handleModalLink = e => {
|
||||
e.preventDefault();
|
||||
|
||||
let href;
|
||||
|
||||
if (e.target.nodeName !== 'A') {
|
||||
href = e.target.parentNode.href;
|
||||
} else {
|
||||
href = e.target.href;
|
||||
}
|
||||
|
||||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
};
|
||||
|
||||
handleTranslate = () => {
|
||||
const { onTranslate, status } = this.props;
|
||||
onTranslate(status);
|
||||
};
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
return status.get('reblog');
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
getAttachmentAspectRatio () {
|
||||
const attachments = this._properStatus().get('media_attachments');
|
||||
|
||||
if (attachments.getIn([0, 'type']) === 'video') {
|
||||
return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`;
|
||||
} else if (attachments.getIn([0, 'type']) === 'audio') {
|
||||
return '16 / 9';
|
||||
} else {
|
||||
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const status = this._properStatus();
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
const { compact, pictureInPicture } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let media = '';
|
||||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let favouriteLink = '';
|
||||
|
||||
if (this.props.measureHeight) {
|
||||
outerStyle.height = `${this.state.height}px`;
|
||||
}
|
||||
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
|
||||
media = (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={this.props.showMedia}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
|
||||
media = (
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
width={300}
|
||||
height={150}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = <>·<a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
|
||||
}
|
||||
|
||||
const visibilityLink = <>·<VisibilityIcon visibility={status.get('visibility')} /></>;
|
||||
|
||||
if (['private', 'direct'].includes(status.get('visibility'))) {
|
||||
reblogLink = '';
|
||||
} else if (this.props.history) {
|
||||
reblogLink = (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
reblogLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
<FormattedMessage id='status.reblogs' defaultMessage='{count, plural, one {boost} other {boosts}}' values={{ count: status.get('reblogs_count') }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.history) {
|
||||
favouriteLink = (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={status.get('favourites_count')} />
|
||||
</span>
|
||||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
favouriteLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={status.get('favourites_count')} />
|
||||
</span>
|
||||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
|
||||
{status.get('visibility') === 'direct' && (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='at' icon={AlternateEmailIcon} className='status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
|
||||
</div>
|
||||
)}
|
||||
<a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
|
||||
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
status={status}
|
||||
onTranslate={this.handleTranslate}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<div className='detailed-status__meta__line'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</a>
|
||||
|
||||
{visibilityLink}
|
||||
|
||||
{applicationLink}
|
||||
</div>
|
||||
|
||||
{status.get('edited_at') && <div className='detailed-status__meta__line'><EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /></div>}
|
||||
|
||||
<div className='detailed-status__meta__line'>
|
||||
{reblogLink}
|
||||
{reblogLink && <>·</>}
|
||||
{favouriteLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(DetailedStatus);
|
|
@ -0,0 +1,390 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access,
|
||||
@typescript-eslint/no-unsafe-call,
|
||||
@typescript-eslint/no-explicit-any,
|
||||
@typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconLogo } from 'mastodon/components/logo';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import Video from '../../video';
|
||||
|
||||
import Card from './card';
|
||||
|
||||
interface VideoModalOptions {
|
||||
startTime: number;
|
||||
autoPlay?: boolean;
|
||||
defaultVolume: number;
|
||||
componentIndex: number;
|
||||
}
|
||||
|
||||
export const DetailedStatus: React.FC<{
|
||||
status: any;
|
||||
onOpenMedia?: (status: any, index: number, lang: string) => void;
|
||||
onOpenVideo?: (status: any, lang: string, options: VideoModalOptions) => void;
|
||||
onTranslate?: (status: any) => void;
|
||||
measureHeight?: boolean;
|
||||
onHeightChange?: () => void;
|
||||
domain: string;
|
||||
showMedia?: boolean;
|
||||
withLogo?: boolean;
|
||||
pictureInPicture: any;
|
||||
onToggleHidden?: (status: any) => void;
|
||||
onToggleMediaVisibility?: () => void;
|
||||
}> = ({
|
||||
status,
|
||||
onOpenMedia,
|
||||
onOpenVideo,
|
||||
onTranslate,
|
||||
measureHeight,
|
||||
onHeightChange,
|
||||
domain,
|
||||
showMedia,
|
||||
withLogo,
|
||||
pictureInPicture,
|
||||
onToggleMediaVisibility,
|
||||
onToggleHidden,
|
||||
}) => {
|
||||
const properStatus = status?.get('reblog') ?? status;
|
||||
const [height, setHeight] = useState(0);
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
|
||||
const handleOpenVideo = useCallback(
|
||||
(options: VideoModalOptions) => {
|
||||
const lang = (status.getIn(['translation', 'language']) ||
|
||||
status.get('language')) as string;
|
||||
if (onOpenVideo)
|
||||
onOpenVideo(status.getIn(['media_attachments', 0]), lang, options);
|
||||
},
|
||||
[onOpenVideo, status],
|
||||
);
|
||||
|
||||
const handleExpandedToggle = useCallback(() => {
|
||||
if (onToggleHidden) onToggleHidden(status);
|
||||
}, [onToggleHidden, status]);
|
||||
|
||||
const _measureHeight = useCallback(
|
||||
(heightJustChanged?: boolean) => {
|
||||
if (measureHeight && nodeRef.current) {
|
||||
scheduleIdleTask(() => {
|
||||
if (nodeRef.current)
|
||||
setHeight(Math.ceil(nodeRef.current.scrollHeight) + 1);
|
||||
});
|
||||
|
||||
if (onHeightChange && heightJustChanged) {
|
||||
onHeightChange();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onHeightChange, measureHeight, setHeight],
|
||||
);
|
||||
|
||||
const handleRef = useCallback(
|
||||
(c: HTMLDivElement) => {
|
||||
nodeRef.current = c;
|
||||
_measureHeight();
|
||||
},
|
||||
[_measureHeight],
|
||||
);
|
||||
|
||||
const handleTranslate = useCallback(() => {
|
||||
if (onTranslate) onTranslate(status);
|
||||
}, [onTranslate, status]);
|
||||
|
||||
if (!properStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let media;
|
||||
let applicationLink;
|
||||
let reblogLink;
|
||||
let attachmentAspectRatio;
|
||||
|
||||
if (properStatus.get('media_attachments').getIn([0, 'type']) === 'video') {
|
||||
attachmentAspectRatio = `${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'width'])} / ${properStatus.get('media_attachments').getIn([0, 'meta', 'original', 'height'])}`;
|
||||
} else if (
|
||||
properStatus.get('media_attachments').getIn([0, 'type']) === 'audio'
|
||||
) {
|
||||
attachmentAspectRatio = '16 / 9';
|
||||
} else {
|
||||
attachmentAspectRatio =
|
||||
properStatus.get('media_attachments').size === 1 &&
|
||||
properStatus
|
||||
.get('media_attachments')
|
||||
.getIn([0, 'meta', 'small', 'aspect'])
|
||||
? properStatus
|
||||
.get('media_attachments')
|
||||
.getIn([0, 'meta', 'small', 'aspect'])
|
||||
: '3 / 2';
|
||||
}
|
||||
|
||||
const outerStyle = { boxSizing: 'border-box' } as CSSProperties;
|
||||
|
||||
if (measureHeight) {
|
||||
outerStyle.height = height;
|
||||
}
|
||||
|
||||
const language =
|
||||
status.getIn(['translation', 'language']) || status.get('language');
|
||||
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder aspectRatio={attachmentAspectRatio} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description =
|
||||
attachment.getIn(['translation', 'description']) ||
|
||||
attachment.get('description');
|
||||
|
||||
media = (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={
|
||||
attachment.get('preview_url') ||
|
||||
status.getIn(['account', 'avatar_static'])
|
||||
}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={showMedia}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description =
|
||||
attachment.getIn(['translation', 'description']) ||
|
||||
attachment.get('description');
|
||||
|
||||
media = (
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
width={300}
|
||||
height={150}
|
||||
onOpenVideo={handleOpenVideo}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media = (
|
||||
<Card
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenMedia={onOpenMedia}
|
||||
card={status.get('card', null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = (
|
||||
<>
|
||||
·
|
||||
<a
|
||||
className='detailed-status__application'
|
||||
href={status.getIn(['application', 'website'])}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{status.getIn(['application', 'name'])}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const visibilityLink = (
|
||||
<>
|
||||
·<VisibilityIcon visibility={status.get('visibility')} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (['private', 'direct'].includes(status.get('visibility') as string)) {
|
||||
reblogLink = '';
|
||||
} else {
|
||||
reblogLink = (
|
||||
<Link
|
||||
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`}
|
||||
className='detailed-status__link'
|
||||
>
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
<FormattedMessage
|
||||
id='status.reblogs'
|
||||
defaultMessage='{count, plural, one {boost} other {boosts}}'
|
||||
values={{ count: status.get('reblogs_count') }}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const favouriteLink = (
|
||||
<Link
|
||||
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`}
|
||||
className='detailed-status__link'
|
||||
>
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={status.get('favourites_count')} />
|
||||
</span>
|
||||
<FormattedMessage
|
||||
id='status.favourites'
|
||||
defaultMessage='{count, plural, one {favorite} other {favorites}}'
|
||||
values={{ count: status.get('favourites_count') }}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
|
||||
status as StatusLike,
|
||||
);
|
||||
const expanded =
|
||||
!status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={handleRef} className={classNames('detailed-status')}>
|
||||
{status.get('visibility') === 'direct' && (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'>
|
||||
<Icon
|
||||
id='at'
|
||||
icon={AlternateEmailIcon}
|
||||
className='status__prepend-icon'
|
||||
/>
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='status.direct_indicator'
|
||||
defaultMessage='Private mention'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||
className='detailed-status__display-name'
|
||||
>
|
||||
<div className='detailed-status__display-avatar'>
|
||||
<Avatar account={status.get('account')} size={46} />
|
||||
</div>
|
||||
<DisplayName account={status.get('account')} localDomain={domain} />
|
||||
{withLogo && (
|
||||
<>
|
||||
<div className='spacer' />
|
||||
<IconLogo />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{status.get('spoiler_text').length > 0 && (
|
||||
<ContentWarning
|
||||
text={
|
||||
status.getIn(['translation', 'spoilerHtml']) ||
|
||||
status.get('spoilerHtml')
|
||||
}
|
||||
expanded={expanded}
|
||||
onClick={handleExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
status={status}
|
||||
onTranslate={handleTranslate}
|
||||
{...(statusContentProps as any)}
|
||||
/>
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<div className='detailed-status__meta__line'>
|
||||
<a
|
||||
className='detailed-status__datetime'
|
||||
href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<FormattedDate
|
||||
value={new Date(status.get('created_at') as string)}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</a>
|
||||
|
||||
{visibilityLink}
|
||||
{applicationLink}
|
||||
</div>
|
||||
|
||||
{status.get('edited_at') && (
|
||||
<div className='detailed-status__meta__line'>
|
||||
<EditedTimestamp
|
||||
statusId={status.get('id')}
|
||||
timestamp={status.get('edited_at')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='detailed-status__meta__line'>
|
||||
{reblogLink}
|
||||
{reblogLink && <>·</>}
|
||||
{favouriteLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,140 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { showAlertForError } from '../../../actions/alerts';
|
||||
import { initBlockModal } from '../../../actions/blocks';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../../../actions/compose';
|
||||
import {
|
||||
toggleReblog,
|
||||
toggleFavourite,
|
||||
pin,
|
||||
unpin,
|
||||
} from '../../../actions/interactions';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
import {
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
deleteStatus,
|
||||
toggleStatusSpoilers,
|
||||
} from '../../../actions/statuses';
|
||||
import { deleteModal } from '../../../initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors';
|
||||
import DetailedStatus from '../components/detailed_status';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, props),
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
pictureInPicture: getPictureInPicture(state, props),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
onReply (status) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
|
||||
} else {
|
||||
dispatch(replyCompose(status));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
dispatch(toggleFavourite(status.get('id')));
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
} else {
|
||||
dispatch(pin(status));
|
||||
}
|
||||
},
|
||||
|
||||
onEmbed (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
modalProps: {
|
||||
id: status.get('id'),
|
||||
onError: error => dispatch(showAlertForError(error)),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
onDelete (status, withRedraft = false) {
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||
} else {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
|
||||
}
|
||||
},
|
||||
|
||||
onDirect (account) {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
|
||||
onMention (account) {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
|
||||
onOpenMedia (media, index, lang) {
|
||||
dispatch(openModal({
|
||||
modalType: 'MEDIA',
|
||||
modalProps: { media, index, lang },
|
||||
}));
|
||||
},
|
||||
|
||||
onOpenVideo (media, lang, options) {
|
||||
dispatch(openModal({
|
||||
modalType: 'VIDEO',
|
||||
modalProps: { media, lang, options },
|
||||
}));
|
||||
},
|
||||
|
||||
onBlock (status) {
|
||||
const account = status.get('account');
|
||||
dispatch(initBlockModal(account));
|
||||
},
|
||||
|
||||
onReport (status) {
|
||||
dispatch(initReport(status.get('account'), status));
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
|
||||
onMuteConversation (status) {
|
||||
if (status.get('muted')) {
|
||||
dispatch(unmuteStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(muteStatus(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onToggleHidden (status) {
|
||||
dispatch(toggleStatusSpoilers(status.get('id')));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
|
@ -69,7 +69,7 @@ import Column from '../ui/components/column';
|
|||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
import ActionBar from './components/action_bar';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
import { DetailedStatus } from './components/detailed_status';
|
||||
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
@import 'mastodon/widgets';
|
||||
@import 'mastodon/forms';
|
||||
@import 'mastodon/accounts';
|
||||
@import 'mastodon/statuses';
|
||||
@import 'mastodon/components';
|
||||
@import 'mastodon/polls';
|
||||
@import 'mastodon/modal';
|
||||
|
|
|
@ -1677,18 +1677,6 @@ body > [data-popper-placement] {
|
|||
padding: 16px;
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
|
||||
&--flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.status__content,
|
||||
.detailed-status__meta {
|
||||
flex: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.status__content {
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
|
@ -1723,6 +1711,25 @@ body > [data-popper-placement] {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.embed {
|
||||
position: relative;
|
||||
|
||||
&__overlay {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable > div:first-child .detailed-status {
|
||||
|
|
|
@ -1,152 +0,0 @@
|
|||
.activity-stream {
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&--under-tabs {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&--headless {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.detailed-status,
|
||||
.status {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
div[data-component] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entry {
|
||||
background: $ui-base-color;
|
||||
|
||||
.detailed-status,
|
||||
.status,
|
||||
.load-more {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.detailed-status,
|
||||
.status,
|
||||
.load-more {
|
||||
border-bottom: 0;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.detailed-status,
|
||||
.status,
|
||||
.load-more {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.detailed-status,
|
||||
.status,
|
||||
.load-more {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 740px) {
|
||||
.detailed-status,
|
||||
.status,
|
||||
.load-more {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--highlighted .entry {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.button.logo-button svg {
|
||||
width: 20px;
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
margin-inline-end: 5px;
|
||||
fill: $primary-text-color;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.embed {
|
||||
.status__content[data-spoiler='folded'] {
|
||||
.e-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p:first-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
padding: 15px;
|
||||
|
||||
.detailed-status__display-avatar .account__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 15px 15px 15px (48px + 15px * 2);
|
||||
min-height: 48px + 2px;
|
||||
|
||||
&__avatar {
|
||||
inset-inline-start: 15px;
|
||||
top: 17px;
|
||||
|
||||
.account__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
&__prepend {
|
||||
margin-inline-start: 48px + 15px * 2;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
&__prepend-icon-wrapper {
|
||||
inset-inline-start: -32px;
|
||||
}
|
||||
|
||||
.media-gallery,
|
||||
&__action-bar,
|
||||
.video-player {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&__action-bar-button {
|
||||
font-size: 18px;
|
||||
width: 23.1429px;
|
||||
height: 23.1429px;
|
||||
line-height: 23.15px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,16 +37,16 @@ class OEmbedSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def html
|
||||
attributes = {
|
||||
src: embed_short_account_status_url(object.account, object),
|
||||
class: 'mastodon-embed',
|
||||
style: 'max-width: 100%; border: 0',
|
||||
width: width,
|
||||
height: height,
|
||||
allowfullscreen: true,
|
||||
}
|
||||
|
||||
content_tag(:iframe, nil, attributes) + content_tag(:script, nil, src: full_asset_url('embed.js', skip_pipeline: true), async: true)
|
||||
<<~HTML.squish
|
||||
<blockquote class="mastodon-embed" data-embed-url="#{embed_short_account_status_url(object.account, object)}" style="max-width: 540px; min-width: 270px; background:#FCF8FF; border: 1px solid #C9C4DA; border-radius: 8px; overflow: hidden; margin: 0; padding: 0;">
|
||||
<a href="#{short_account_status_url(object.account, object)}" target="_blank" style="color: #1C1A25; text-decoration: none; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 24px; font-size: 14px; line-height: 20px; letter-spacing: 0.25px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Roboto, sans-serif;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 79 75"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></svg>
|
||||
<div style="margin-top: 16px; color: #787588;">Post by @#{object.account.pretty_acct}@#{provider_name}</div>
|
||||
<div style="font-weight: 500;">View on Mastodon</div>
|
||||
</a>
|
||||
</blockquote>
|
||||
<script data-allowed-prefixes="#{root_url}" src="#{full_asset_url('embed.js', skip_pipeline: true)}" async="true"></script>
|
||||
HTML
|
||||
end
|
||||
|
||||
def width
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
= javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
|
||||
= preload_pack_asset "locale/#{I18n.locale}-json.js"
|
||||
= render_initial_state
|
||||
= javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
|
||||
= javascript_pack_tag 'embed', integrity: true, crossorigin: 'anonymous'
|
||||
%body.embed
|
||||
= yield
|
||||
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
|
||||
.p-author.h-card
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
|
||||
.detailed-status__display-avatar
|
||||
- if prefers_autoplay?
|
||||
= image_tag status.account.avatar_original_url, alt: '', class: 'account__avatar u-photo'
|
||||
- else
|
||||
= image_tag status.account.avatar_static_url, alt: '', class: 'account__avatar u-photo'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: prefers_autoplay?)
|
||||
%span.display-name__account
|
||||
= acct(status.account)
|
||||
= material_symbol('lock') if status.account.locked?
|
||||
|
||||
= account_action_button(status.account)
|
||||
|
||||
.status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
|
||||
- if status.spoiler_text?
|
||||
%p<
|
||||
%span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content{ lang: status.language }
|
||||
= prerender_custom_emojis(status_content_format(status), status.emojis)
|
||||
|
||||
- if status.preloadable_poll
|
||||
= render_poll_component(status)
|
||||
|
||||
- if !status.ordered_media_attachments.empty?
|
||||
- if status.ordered_media_attachments.first.video?
|
||||
= render_video_component(status, width: 670, height: 380, detailed: true)
|
||||
- elsif status.ordered_media_attachments.first.audio?
|
||||
= render_audio_component(status, width: 670, height: 380)
|
||||
- else
|
||||
= render_media_gallery_component(status, height: 380, standalone: true)
|
||||
- elsif status.preview_card
|
||||
= render_card_component(status)
|
||||
|
||||
.detailed-status__meta
|
||||
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
||||
- if status.edited?
|
||||
%data.dt-updated{ value: status.edited_at.to_time.iso8601 }
|
||||
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
·
|
||||
- if status.edited?
|
||||
= t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted'))
|
||||
·
|
||||
%span.detailed-status__visibility-icon
|
||||
= visibility_icon status
|
||||
·
|
||||
- if status.application && status.account.user&.setting_show_application
|
||||
- if status.application.website.blank?
|
||||
%strong.detailed-status__application= status.application.name
|
||||
- else
|
||||
= link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener noreferrer'
|
||||
·
|
||||
%span.detailed-status__link
|
||||
- if status.in_reply_to_id.nil?
|
||||
= material_symbol('reply')
|
||||
- else
|
||||
= material_symbol('reply_all')
|
||||
%span.detailed-status__reblogs>= friendly_number_to_human status.replies_count
|
||||
|
||||
·
|
||||
- if status.public_visibility? || status.unlisted_visibility?
|
||||
%span.detailed-status__link
|
||||
= material_symbol('repeat')
|
||||
%span.detailed-status__reblogs>= friendly_number_to_human status.reblogs_count
|
||||
|
||||
·
|
||||
%span.detailed-status__link
|
||||
= material_symbol('star')
|
||||
%span.detailed-status__favorites>= friendly_number_to_human status.favourites_count
|
||||
|
||||
|
||||
- if user_signed_in?
|
||||
·
|
||||
= link_to t('statuses.open_in_web'), web_url("@#{status.account.pretty_acct}/#{status.id}"), class: 'detailed-status__application', target: '_blank', rel: 'noopener noreferrer'
|
|
@ -1,36 +0,0 @@
|
|||
:ruby
|
||||
show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
|
||||
total_votes_count = poll.voters_count || poll.votes_count
|
||||
|
||||
.poll
|
||||
%ul
|
||||
- poll.loaded_options.each do |option|
|
||||
%li
|
||||
- if show_results
|
||||
- percent = total_votes_count.positive? ? 100 * option.votes_count / total_votes_count : 0
|
||||
%label.poll__option><
|
||||
%span.poll__number><
|
||||
#{percent.round}%
|
||||
%span.poll__option__text
|
||||
= prerender_custom_emojis(h(option.title), status.emojis)
|
||||
|
||||
%progress{ max: 100, value: [percent, 1].max, 'aria-hidden': 'true' }
|
||||
%span.poll__chart
|
||||
- else
|
||||
%label.poll__option><
|
||||
%span.poll__input{ class: poll.multiple? ? 'checkbox' : nil }><
|
||||
%span.poll__option__text
|
||||
= prerender_custom_emojis(h(option.title), status.emojis)
|
||||
.poll__footer
|
||||
- unless show_results
|
||||
%button.button.button-secondary{ disabled: true }
|
||||
= t('statuses.poll.vote')
|
||||
|
||||
- if poll.voters_count.nil?
|
||||
%span= t('statuses.poll.total_votes', count: poll.votes_count)
|
||||
- else
|
||||
%span= t('statuses.poll.total_people', count: poll.voters_count)
|
||||
|
||||
- unless poll.expires_at.nil?
|
||||
·
|
||||
%span= l poll.expires_at
|
|
@ -1,70 +0,0 @@
|
|||
:ruby
|
||||
hide_show_thread ||= false
|
||||
|
||||
.status{ class: "status-#{status.visibility}" }
|
||||
.status__info
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
%span.status__visibility-icon><
|
||||
= visibility_icon status
|
||||
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
- if status.edited?
|
||||
%abbr{ title: t('statuses.edited_at_html', date: l(status.edited_at.to_date)) }
|
||||
*
|
||||
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
||||
|
||||
.p-author.h-card
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
.status__avatar
|
||||
%div
|
||||
- if prefers_autoplay?
|
||||
= image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
|
||||
- else
|
||||
= image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: prefers_autoplay?)
|
||||
|
||||
%span.display-name__account
|
||||
= acct(status.account)
|
||||
= material_symbol('lock') if status.account.locked?
|
||||
.status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
|
||||
- if status.spoiler_text?
|
||||
%p<
|
||||
%span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content{ lang: status.language }
|
||||
= prerender_custom_emojis(status_content_format(status), status.emojis)
|
||||
|
||||
- if status.preloadable_poll
|
||||
= render_poll_component(status)
|
||||
|
||||
- if !status.ordered_media_attachments.empty?
|
||||
- if status.ordered_media_attachments.first.video?
|
||||
= render_video_component(status, width: 610, height: 343)
|
||||
- elsif status.ordered_media_attachments.first.audio?
|
||||
= render_audio_component(status, width: 610, height: 343)
|
||||
- else
|
||||
= render_media_gallery_component(status, height: 343)
|
||||
- elsif status.preview_card
|
||||
= render_card_component(status)
|
||||
|
||||
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id && !hide_show_thread
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
= t 'statuses.show_thread'
|
||||
|
||||
.status__action-bar
|
||||
%span.status__action-bar-button.icon-button.icon-button--with-counter
|
||||
- if status.in_reply_to_id.nil?
|
||||
= material_symbol 'reply'
|
||||
- else
|
||||
= material_symbol 'reply_all'
|
||||
%span.icon-button__counter= obscured_counter status.replies_count
|
||||
%span.status__action-bar-button.icon-button
|
||||
- if status.distributable?
|
||||
= material_symbol 'repeat'
|
||||
- elsif status.private_visibility? || status.limited_visibility?
|
||||
= material_symbol 'lock'
|
||||
- else
|
||||
= material_symbol 'alternate_email'
|
||||
%span.status__action-bar-button.icon-button
|
||||
= material_symbol 'star'
|
|
@ -1,2 +0,0 @@
|
|||
.entry
|
||||
= render (centered ? 'statuses/detailed_status' : 'statuses/simple_status'), status: status.proper, hide_show_thread: false
|
|
@ -1,2 +1 @@
|
|||
.activity-stream.activity-stream--headless
|
||||
= render 'status', status: @status, centered: true
|
||||
#mastodon-status{ data: { props: Oj.dump(default_props.merge(id: @status.id.to_s)) } }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue