mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-22 07:08:07 +01:00
Merge pull request #2843 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 0226bbe516
This commit is contained in:
commit
065abf2918
359 changed files with 3333 additions and 3662 deletions
|
@ -333,7 +333,7 @@ module.exports = defineConfig({
|
|||
],
|
||||
|
||||
parserOptions: {
|
||||
project: true,
|
||||
projectService: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
|
||||
|
|
16
Gemfile.lock
16
Gemfile.lock
|
@ -100,17 +100,17 @@ GEM
|
|||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.970.0)
|
||||
aws-sdk-core (3.203.0)
|
||||
aws-partitions (1.974.0)
|
||||
aws-sdk-core (3.205.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.89.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sdk-kms (1.91.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.160.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sdk-s3 (1.162.0)
|
||||
aws-sdk-core (~> 3, >= 3.205.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.9.1)
|
||||
|
@ -601,7 +601,7 @@ GEM
|
|||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
propshaft (0.9.1)
|
||||
propshaft (1.0.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
|
@ -691,7 +691,7 @@ GEM
|
|||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.6.0)
|
||||
rack (>= 1.4)
|
||||
|
|
|
@ -8,6 +8,16 @@ module WebAppControllerConcern
|
|||
|
||||
before_action :redirect_unauthenticated_to_permalinks!
|
||||
before_action :set_app_body_class
|
||||
|
||||
content_security_policy do |p|
|
||||
policy = ContentSecurityPolicy.new
|
||||
|
||||
if policy.sso_host.present?
|
||||
p.form_action policy.sso_host
|
||||
else
|
||||
p.form_action :none
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def skip_csrf_meta_tags?
|
||||
|
|
|
@ -4,7 +4,6 @@ class Redirect::BaseController < ApplicationController
|
|||
vary_by 'Accept-Language'
|
||||
|
||||
before_action :set_resource
|
||||
before_action :set_app_body_class
|
||||
|
||||
def show
|
||||
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
|
||||
|
@ -14,10 +13,6 @@ class Redirect::BaseController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def set_app_body_class
|
||||
@body_classes = 'app-body'
|
||||
end
|
||||
|
||||
def set_resource
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
|
|
@ -11,7 +11,6 @@ class StatusesController < ApplicationController
|
|||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_status
|
||||
before_action :redirect_to_original, only: :show
|
||||
before_action :set_body_classes, only: :embed
|
||||
|
||||
after_action :set_link_headers
|
||||
|
||||
|
@ -51,10 +50,6 @@ class StatusesController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'with-modals'
|
||||
end
|
||||
|
||||
def set_link_headers
|
||||
response.headers['Link'] = LinkHeader.new(
|
||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||
|
|
|
@ -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 hide_followers_count?(account)
|
||||
Setting.hide_followers_count || account.user&.settings&.[]('hide_followers_count')
|
||||
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
|
||||
|
|
|
@ -4,6 +4,13 @@ module StatusesHelper
|
|||
EMBEDDED_CONTROLLER = 'statuses'
|
||||
EMBEDDED_ACTION = 'embed'
|
||||
|
||||
VISIBLITY_ICONS = {
|
||||
public: 'globe',
|
||||
unlisted: 'lock_open',
|
||||
private: 'lock',
|
||||
direct: 'alternate_email',
|
||||
}.freeze
|
||||
|
||||
def nothing_here(extra_classes = '')
|
||||
content_tag(:div, class: "nothing-here #{extra_classes}") do
|
||||
t('accounts.nothing_here')
|
||||
|
@ -57,17 +64,8 @@ module StatusesHelper
|
|||
embedded_view? ? '_blank' : nil
|
||||
end
|
||||
|
||||
def fa_visibility_icon(status)
|
||||
case status.visibility
|
||||
when 'public'
|
||||
material_symbol 'globe'
|
||||
when 'unlisted'
|
||||
material_symbol 'lock_open'
|
||||
when 'private'
|
||||
material_symbol 'lock'
|
||||
when 'direct'
|
||||
material_symbol 'alternate_email'
|
||||
end
|
||||
def visibility_icon(status)
|
||||
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||
end
|
||||
|
||||
def embedded_view?
|
||||
|
|
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();
|
||||
|
||||
|
|
|
@ -65,7 +65,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
|||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -151,7 +151,7 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) {
|
|||
// TODO: polling for merged notifications
|
||||
try {
|
||||
await dispatch(pollRecentGroupNotifications());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// TODO
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -5,7 +5,7 @@ export function start() {
|
|||
|
||||
try {
|
||||
Rails.start();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If called twice
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import { useRef, useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
|
||||
|
||||
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [setAnimationTimeout] = useTimeout();
|
||||
|
||||
const handleInputClick = useCallback(() => {
|
||||
setCopied(false);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
inputRef.current.setSelectionRange(0, value.length);
|
||||
}
|
||||
}, [setCopied, value]);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(value);
|
||||
inputRef.current?.blur();
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== ' ') return;
|
||||
void navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
}, [setFocused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('copy-paste-text', { copied, focused })}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleInputClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
value={value}
|
||||
ref={inputRef}
|
||||
onClick={handleInputClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button className='button' onClick={handleButtonClick}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} />{' '}
|
||||
{copied ? (
|
||||
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='copypaste.copy_to_clipboard'
|
||||
defaultMessage='Copy to clipboard'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
|
|||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} catch {
|
||||
// do nothing
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
|
|
@ -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' />
|
||||
);
|
||||
|
|
|
@ -312,7 +312,7 @@ class MediaGallery extends PureComponent {
|
|||
|
||||
const style = {};
|
||||
|
||||
const computedClass = classNames('media-gallery', { 'full-width': fullwidth });
|
||||
const computedClass = classNames('media-gallery', `media-gallery--layout-${size}`, { 'full-width': fullwidth });
|
||||
|
||||
if (this.isStandaloneEligible()) { // TODO: cropImages setting
|
||||
style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
|
||||
|
|
|
@ -2,14 +2,12 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconLogo } from 'flavours/glitch/components/logo';
|
||||
import { AuthorLink } from 'flavours/glitch/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>
|
||||
);
|
||||
|
|
|
@ -56,7 +56,7 @@ const messages = defineMessages({
|
|||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||
|
|
|
@ -34,8 +34,6 @@ import Status from 'flavours/glitch/components/status';
|
|||
import { deleteModal } from 'flavours/glitch/initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
|
||||
import { showAlertForError } from '../actions/alerts';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
@ -111,10 +109,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
onEmbed (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
modalProps: {
|
||||
id: status.get('id'),
|
||||
onError: error => dispatch(showAlertForError(error)),
|
||||
},
|
||||
modalProps: { id: status.get('id') },
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
74
app/javascript/flavours/glitch/entrypoints/embed.tsx
Normal file
74
app/javascript/flavours/glitch/entrypoints/embed.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '@/entrypoints/public-path';
|
||||
|
||||
import { start } from 'flavours/glitch/common';
|
||||
import { Status } from 'flavours/glitch/features/standalone/status';
|
||||
import { afterInitialRender } from 'flavours/glitch/hooks/useRenderSignal';
|
||||
import { loadPolyfills } from 'flavours/glitch/polyfills';
|
||||
import ready from 'flavours/glitch/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();
|
||||
|
||||
|
|
|
@ -131,7 +131,7 @@ class LoginForm extends React.PureComponent {
|
|||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch(_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
|
|||
status.get('visibility') === 'direct',
|
||||
status.get('in_reply_to_account_id') === me,
|
||||
] as const;
|
||||
});
|
||||
}, isEqual);
|
||||
|
||||
let labelRenderer = mentionLabelRenderer;
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
|
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
|
|||
multiColumn?: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const notifications = useAppSelector(selectNotificationGroups);
|
||||
const notifications = useAppSelector(selectNotificationGroups, isEqual);
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||
|
|
|
@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
|
|||
import SwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { me, domain } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
@ -20,67 +20,6 @@ const messages = defineMessages({
|
|||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||
});
|
||||
|
||||
class CopyPasteText extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
focused: false,
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.input = c;
|
||||
};
|
||||
|
||||
handleInputClick = () => {
|
||||
this.setState({ copied: false });
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
this.input.setSelectionRange(0, this.props.value.length);
|
||||
};
|
||||
|
||||
handleButtonClick = e => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.input.blur();
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ focused: false });
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value } = this.props;
|
||||
const { copied, focused } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
|
||||
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
|
||||
|
||||
<button className='button' onClick={this.handleButtonClick}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TipCarousel extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
/* 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 {
|
||||
fetchStatus,
|
||||
toggleStatusSpoilers,
|
||||
} from 'flavours/glitch/actions/statuses';
|
||||
import { hydrateStore } from 'flavours/glitch/actions/store';
|
||||
import { Router } from 'flavours/glitch/components/router';
|
||||
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
|
||||
import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal';
|
||||
import initialState from 'flavours/glitch/initial_state';
|
||||
import { IntlProvider } from 'flavours/glitch/locales';
|
||||
import {
|
||||
makeGetStatus,
|
||||
makeGetPictureInPicture,
|
||||
} from 'flavours/glitch/selectors';
|
||||
import { store, useAppSelector, useAppDispatch } from 'flavours/glitch/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}
|
||||
expanded={false}
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -49,7 +49,7 @@ const messages = defineMessages({
|
|||
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||
|
|
|
@ -1,336 +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 { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
||||
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/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,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
onToggleHidden: PropTypes.func,
|
||||
onTranslate: PropTypes.func.isRequired,
|
||||
expanded: PropTypes.bool,
|
||||
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.altKey || e.metaKey) && this.props.history) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
parseClick = (e, destination) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.props.history) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(destination);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
handleOpenVideo = (options) => {
|
||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
|
||||
};
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
handleChildUpdate = () => {
|
||||
this._measureHeight();
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
render () {
|
||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
const { compact, pictureInPicture, expanded, onToggleHidden, settings } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let favouriteLink = '';
|
||||
|
||||
// Depending on user settings, some media are considered as parts of the
|
||||
// contents (affected by CW) while other will be displayed outside of the
|
||||
// CW.
|
||||
let contentMedia = [];
|
||||
let contentMediaIcons = [];
|
||||
let extraMedia = [];
|
||||
let extraMediaIcons = [];
|
||||
let media = contentMedia;
|
||||
let mediaIcons = contentMediaIcons;
|
||||
|
||||
if (settings.getIn(['content_warnings', 'media_outside'])) {
|
||||
media = extraMedia;
|
||||
mediaIcons = extraMediaIcons;
|
||||
}
|
||||
|
||||
if (this.props.measureHeight) {
|
||||
outerStyle.height = `${this.state.height}px`;
|
||||
}
|
||||
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media.push(<PictureInPicturePlaceholder />);
|
||||
mediaIcons.push('video-camera');
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||
} else 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.push(
|
||||
<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}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('music');
|
||||
} 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.push(
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
||||
preventPlayback={!expanded}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
autoplay
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={settings.getIn(['media', 'fullwidth'])}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
} else if (status.get('card')) {
|
||||
media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
|
||||
mediaIcons.push('link');
|
||||
}
|
||||
|
||||
if (status.get('poll')) {
|
||||
contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
|
||||
contentMediaIcons.push('tasks');
|
||||
}
|
||||
|
||||
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 (!['unlisted', 'public'].includes(status.get('visibility'))) {
|
||||
reblogLink = null;
|
||||
} 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);
|
||||
contentMedia.push(hashtagBar);
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
||||
<a href={status.getIn(['account', 'url'])} 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={48} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
media={contentMedia}
|
||||
extraMedia={extraMedia}
|
||||
mediaIcons={contentMediaIcons}
|
||||
expanded={expanded}
|
||||
collapsed={false}
|
||||
onExpandedToggle={onToggleHidden}
|
||||
onTranslate={this.handleTranslate}
|
||||
parseClick={this.parseClick}
|
||||
onUpdate={this.handleChildUpdate}
|
||||
tagLinks={settings.get('tag_misleading_links')}
|
||||
rewriteMentions={settings.get('rewrite_mentions')}
|
||||
disabled
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<div className='detailed-status__meta__line'>
|
||||
<a className='detailed-status__datetime' href={status.get('url')} 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,427 @@
|
|||
/* 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 { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
||||
import type { StatusLike } from 'flavours/glitch/components/hashtag_bar';
|
||||
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
|
||||
import { IconLogo } from 'flavours/glitch/components/logo';
|
||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||
import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
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;
|
||||
expanded: boolean;
|
||||
}> = ({
|
||||
status,
|
||||
onOpenMedia,
|
||||
onOpenVideo,
|
||||
onTranslate,
|
||||
measureHeight,
|
||||
onHeightChange,
|
||||
domain,
|
||||
showMedia,
|
||||
withLogo,
|
||||
pictureInPicture,
|
||||
onToggleMediaVisibility,
|
||||
onToggleHidden,
|
||||
expanded,
|
||||
}) => {
|
||||
const properStatus = status?.get('reblog') ?? status;
|
||||
const [height, setHeight] = useState(0);
|
||||
const nodeRef = useRef<HTMLDivElement>();
|
||||
const history = useAppHistory();
|
||||
|
||||
const rewriteMentions = useAppSelector(
|
||||
(state) => state.local_settings.get('rewrite_mentions', false) as boolean,
|
||||
);
|
||||
const tagMisleadingLinks = useAppSelector(
|
||||
(state) =>
|
||||
state.local_settings.get('tag_misleading_links', false) as boolean,
|
||||
);
|
||||
const mediaOutsideCW = useAppSelector(
|
||||
(state) =>
|
||||
state.local_settings.getIn(
|
||||
['content_warnings', 'media_outside'],
|
||||
false,
|
||||
) as boolean,
|
||||
);
|
||||
const letterboxMedia = useAppSelector(
|
||||
(state) =>
|
||||
state.local_settings.getIn(['media', 'letterbox'], false) as boolean,
|
||||
);
|
||||
const fullwidthMedia = useAppSelector(
|
||||
(state) =>
|
||||
state.local_settings.getIn(['media', 'fullwidth'], false) as boolean,
|
||||
);
|
||||
|
||||
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 _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 handleChildUpdate = useCallback(() => {
|
||||
_measureHeight();
|
||||
}, [_measureHeight]);
|
||||
|
||||
const handleTranslate = useCallback(() => {
|
||||
if (onTranslate) onTranslate(status);
|
||||
}, [onTranslate, status]);
|
||||
|
||||
const parseClick = useCallback(
|
||||
(e: React.MouseEvent, destination: string) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
history.push(destination);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
if (!properStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let applicationLink;
|
||||
let reblogLink;
|
||||
|
||||
// Depending on user settings, some media are considered as parts of the
|
||||
// contents (affected by CW) while other will be displayed outside of the
|
||||
// CW.
|
||||
const contentMedia: React.ReactNode[] = [];
|
||||
const contentMediaIcons: string[] = [];
|
||||
const extraMedia: React.ReactNode[] = [];
|
||||
const extraMediaIcons: string[] = [];
|
||||
let media = contentMedia;
|
||||
let mediaIcons: string[] = contentMediaIcons;
|
||||
|
||||
if (mediaOutsideCW) {
|
||||
media = extraMedia;
|
||||
mediaIcons = extraMediaIcons;
|
||||
}
|
||||
|
||||
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.push(<PictureInPicturePlaceholder />);
|
||||
mediaIcons.push('video-camera');
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (
|
||||
status
|
||||
.get('media_attachments')
|
||||
.some(
|
||||
(item: Immutable.Map<string, any>) => item.get('type') === 'unknown',
|
||||
)
|
||||
) {
|
||||
media.push(<AttachmentList media={status.get('media_attachments')} />);
|
||||
} else 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.push(
|
||||
<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}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('music');
|
||||
} 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.push(
|
||||
<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}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
preventPlayback={!expanded}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('video-camera');
|
||||
} else {
|
||||
media.push(
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
height={300}
|
||||
letterbox={letterboxMedia}
|
||||
fullwidth={fullwidthMedia}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('picture-o');
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media.push(
|
||||
<Card
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenMedia={onOpenMedia}
|
||||
card={status.get('card', null)}
|
||||
/>,
|
||||
);
|
||||
mediaIcons.push('link');
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
contentMedia.push(hashtagBar);
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div
|
||||
ref={handleRef}
|
||||
className={classNames(
|
||||
'detailed-status',
|
||||
`detailed-status-${status.get('visibility')}`,
|
||||
)}
|
||||
data-status-by={status.getIn(['account', 'acct'])}
|
||||
>
|
||||
<Permalink
|
||||
to={`/@${status.getIn(['account', 'acct'])}`}
|
||||
href={status.getIn(['account', 'url'])}
|
||||
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 />
|
||||
</>
|
||||
)}
|
||||
</Permalink>
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
media={contentMedia}
|
||||
extraMedia={extraMedia}
|
||||
mediaIcons={contentMediaIcons}
|
||||
expanded={expanded}
|
||||
collapsed={false}
|
||||
onExpandedToggle={onToggleHidden}
|
||||
onTranslate={handleTranslate}
|
||||
onUpdate={handleChildUpdate}
|
||||
tagLinks={tagMisleadingLinks}
|
||||
rewriteMentions={rewriteMentions}
|
||||
parseClick={parseClick}
|
||||
{...(statusContentProps as any)}
|
||||
/>
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<div className='detailed-status__meta__line'>
|
||||
<a
|
||||
className='detailed-status__datetime'
|
||||
href={status.get('url')}
|
||||
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,134 +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,
|
||||
} from '../../../actions/statuses';
|
||||
import { deleteModal } from '../../../initial_state';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
import DetailedStatus from '../components/detailed_status';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, props),
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
settings: state.get('local_settings'),
|
||||
});
|
||||
|
||||
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, e) {
|
||||
dispatch(toggleFavourite(status.get('id'), e.shiftKey));
|
||||
},
|
||||
|
||||
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')));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
|
@ -63,7 +63,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({
|
||||
|
|
|
@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -79,7 +79,10 @@ export const ConfirmationModal: React.FC<
|
|||
/>
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>{confirm}</Button>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
{confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import api from 'flavours/glitch/api';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class EmbedModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: false,
|
||||
oembed: null,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { id } = this.props;
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
api().get(`/api/web/embeds/${id}`).then(res => {
|
||||
this.setState({ loading: false, oembed: res.data });
|
||||
|
||||
const iframeDocument = this.iframe.contentWindow.document;
|
||||
|
||||
iframeDocument.open();
|
||||
iframeDocument.write(res.data.html);
|
||||
iframeDocument.close();
|
||||
|
||||
iframeDocument.body.style.margin = 0;
|
||||
this.iframe.width = iframeDocument.body.scrollWidth;
|
||||
this.iframe.height = iframeDocument.body.scrollHeight;
|
||||
}).catch(error => {
|
||||
this.props.onError(error);
|
||||
});
|
||||
}
|
||||
|
||||
setIframeRef = c => {
|
||||
this.iframe = c;
|
||||
};
|
||||
|
||||
handleTextareaClick = (e) => {
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, onClose } = this.props;
|
||||
const { oembed } = this.state;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal report-modal embed-modal'>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={16} />
|
||||
<FormattedMessage id='status.embed' defaultMessage='Embed' />
|
||||
</div>
|
||||
|
||||
<div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
|
||||
<p className='hint'>
|
||||
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
|
||||
</p>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='embed-modal__html'
|
||||
readOnly
|
||||
value={oembed && oembed.html || ''}
|
||||
onClick={this.handleTextareaClick}
|
||||
/>
|
||||
|
||||
<p className='hint'>
|
||||
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
|
||||
</p>
|
||||
|
||||
<iframe
|
||||
className='embed-modal__iframe'
|
||||
frameBorder='0'
|
||||
ref={this.setIframeRef}
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='preview'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(EmbedModal);
|
|
@ -0,0 +1,116 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { showAlertForError } from 'flavours/glitch/actions/alerts';
|
||||
import api from 'flavours/glitch/api';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
interface OEmbedResponse {
|
||||
html: string;
|
||||
}
|
||||
|
||||
const EmbedModal: React.FC<{
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
}> = ({ id, onClose }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const [oembed, setOembed] = useState<OEmbedResponse | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
api()
|
||||
.get(`/api/web/embeds/${id}`)
|
||||
.then((res) => {
|
||||
const data = res.data as OEmbedResponse;
|
||||
|
||||
setOembed(data);
|
||||
|
||||
const iframeDocument = iframeRef.current?.contentWindow?.document;
|
||||
|
||||
if (!iframeDocument) {
|
||||
return '';
|
||||
}
|
||||
|
||||
iframeDocument.open();
|
||||
iframeDocument.write(data.html);
|
||||
iframeDocument.close();
|
||||
|
||||
iframeDocument.body.style.margin = '0px';
|
||||
|
||||
// This is our best chance to ensure the parent iframe has the correct height...
|
||||
intervalRef.current = setInterval(
|
||||
() =>
|
||||
window.requestAnimationFrame(() => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.width = `${iframeDocument.body.scrollWidth}px`;
|
||||
iframeRef.current.height = `${iframeDocument.body.scrollHeight}px`;
|
||||
}
|
||||
}),
|
||||
100,
|
||||
);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
dispatch(showAlertForError(error));
|
||||
});
|
||||
}, [dispatch, id, setOembed]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id='report.close' defaultMessage='Done' />
|
||||
</Button>
|
||||
<span className='dialog-modal__header__title'>
|
||||
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
|
||||
</span>
|
||||
<Button secondary onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div className='dialog-modal__content__form'>
|
||||
<FormattedMessage
|
||||
id='embed.instructions'
|
||||
defaultMessage='Embed this status on your website by copying the code below.'
|
||||
/>
|
||||
|
||||
<CopyPasteText value={oembed?.html ?? ''} />
|
||||
|
||||
<FormattedMessage
|
||||
id='embed.preview'
|
||||
defaultMessage='Here is what it will look like:'
|
||||
/>
|
||||
|
||||
<iframe
|
||||
frameBorder='0'
|
||||
ref={iframeRef}
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='Preview'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default EmbedModal;
|
|
@ -137,7 +137,7 @@ export const MuteModal = ({ accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -3,10 +3,12 @@ import { connect } from 'react-redux';
|
|||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
||||
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
||||
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
|
||||
props: state.getIn(['modal', 'stack', 0, 'modalProps'], defaultProps),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -4,24 +4,11 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { NotificationStack } from 'react-notification';
|
||||
|
||||
import { dismissAlert } from '../../../actions/alerts';
|
||||
import { getAlerts } from '../../../selectors';
|
||||
|
||||
const formatIfNeeded = (intl, message, values) => {
|
||||
if (typeof message === 'object') {
|
||||
return intl.formatMessage(message, values);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
import { dismissAlert } from 'flavours/glitch/actions/alerts';
|
||||
import { getAlerts } from 'flavours/glitch/selectors';
|
||||
|
||||
const mapStateToProps = (state, { intl }) => ({
|
||||
notifications: getAlerts(state).map(alert => ({
|
||||
...alert,
|
||||
action: formatIfNeeded(intl, alert.action, alert.values),
|
||||
title: formatIfNeeded(intl, alert.title, alert.values),
|
||||
message: formatIfNeeded(intl, alert.message, alert.values),
|
||||
})),
|
||||
notifications: getAlerts(state, { intl }),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
|
|
@ -15,7 +15,7 @@ const getRegex = createSelector([
|
|||
|
||||
try {
|
||||
regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Bad regex, don't affect filters
|
||||
}
|
||||
return regex;
|
||||
|
|
|
@ -337,8 +337,8 @@ class UI extends PureComponent {
|
|||
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} catch (err) {
|
||||
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
32
app/javascript/flavours/glitch/hooks/useRenderSignal.ts
Normal file
32
app/javascript/flavours/glitch/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();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
|
@ -90,7 +90,7 @@ if (initialState) {
|
|||
try {
|
||||
// @ts-expect-error
|
||||
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
initialState.local_settings = {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ function onProviderError(error: unknown) {
|
|||
error &&
|
||||
typeof error === 'object' &&
|
||||
error instanceof Error &&
|
||||
error.message.match('MISSING_DATA')
|
||||
/MISSING_DATA/.exec(error.message)
|
||||
) {
|
||||
console.warn(error.message);
|
||||
}
|
||||
|
|
|
@ -7,14 +7,16 @@ import { me } from '../initial_state';
|
|||
|
||||
export { makeGetAccount } from "./accounts";
|
||||
|
||||
const getFilters = (state, { contextType }) => {
|
||||
if (!contextType) return null;
|
||||
const getFilters = createSelector([state => state.get('filters'), (_, { contextType }) => contextType], (filters, contextType) => {
|
||||
if (!contextType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverSideType = toServerSideType(contextType);
|
||||
const now = new Date();
|
||||
const serverSideType = toServerSideType(contextType);
|
||||
|
||||
return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
|
||||
};
|
||||
return filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
|
||||
});
|
||||
|
||||
export const makeGetStatus = () => {
|
||||
return createSelector(
|
||||
|
@ -74,10 +76,21 @@ const ALERT_DEFAULTS = {
|
|||
style: false,
|
||||
};
|
||||
|
||||
export const getAlerts = createSelector(state => state.get('alerts'), alerts =>
|
||||
const formatIfNeeded = (intl, message, values) => {
|
||||
if (typeof message === 'object') {
|
||||
return intl.formatMessage(message, values);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export const getAlerts = createSelector([state => state.get('alerts'), (_, { intl }) => intl], (alerts, intl) =>
|
||||
alerts.map(item => ({
|
||||
...ALERT_DEFAULTS,
|
||||
...item,
|
||||
action: formatIfNeeded(intl, item.action, item.values),
|
||||
title: formatIfNeeded(intl, item.title, item.values),
|
||||
message: formatIfNeeded(intl, item.message, item.values),
|
||||
})).toArray());
|
||||
|
||||
export const makeGetNotification = () => createSelector([
|
||||
|
|
|
@ -14,7 +14,7 @@ export default class Settings {
|
|||
const encodedData = JSON.stringify(data);
|
||||
localStorage.setItem(key, encodedData);
|
||||
return data;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export default class Settings {
|
|||
try {
|
||||
const rawData = localStorage.getItem(key);
|
||||
return JSON.parse(rawData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,8 @@ export default class Settings {
|
|||
const key = this.generateKey(id);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore if the key is not found
|
||||
}
|
||||
}
|
||||
return data;
|
||||
|
|
|
@ -30,7 +30,7 @@ function isActionWithmaybeAlertParams(
|
|||
return isAction(action);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
export const errorsMiddleware: Middleware<{}, RootState> =
|
||||
({ dispatch }) =>
|
||||
(next) =>
|
||||
|
|
|
@ -51,7 +51,7 @@ const play = (audio: HTMLAudioElement) => {
|
|||
};
|
||||
|
||||
export const soundsMiddleware = (): Middleware<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- we need to use `{}` here to ensure the dispatch types can be merged
|
||||
{},
|
||||
RootState
|
||||
> => {
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
@import 'widgets';
|
||||
@import 'forms';
|
||||
@import 'accounts';
|
||||
@import 'statuses';
|
||||
@import 'components';
|
||||
@import 'polls';
|
||||
@import 'modal';
|
||||
|
|
|
@ -1849,18 +1849,6 @@ body > [data-popper-placement] {
|
|||
padding: 14px 10px; // glitch: reduced padding
|
||||
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;
|
||||
|
@ -1887,6 +1875,29 @@ body > [data-popper-placement] {
|
|||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.detailed-status {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable > div:first-child .detailed-status {
|
||||
|
@ -4098,7 +4109,7 @@ input.glitch-setting-text {
|
|||
|
||||
&__wrapper {
|
||||
background: $white;
|
||||
border: 1px solid $ui-secondary-color;
|
||||
border: 1px solid var(--background-border-color);
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
|
||||
|
@ -6730,6 +6741,50 @@ a.status-card {
|
|||
}
|
||||
}
|
||||
|
||||
.dialog-modal {
|
||||
width: 588px;
|
||||
max-height: 80vh;
|
||||
flex-direction: column;
|
||||
background: var(--modal-background-color);
|
||||
backdrop-filter: var(--background-filter);
|
||||
border: 1px solid var(--modal-border-color);
|
||||
border-radius: 16px;
|
||||
|
||||
&__header {
|
||||
border-bottom: 1px solid var(--modal-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: row-reverse;
|
||||
padding: 12px 24px;
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
overflow-y: auto;
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-paste-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hotkey-combination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
@ -6747,26 +6802,20 @@ a.status-card {
|
|||
.report-modal,
|
||||
.actions-modal,
|
||||
.compare-history-modal {
|
||||
background: lighten($ui-secondary-color, 8%);
|
||||
color: $inverted-text-color;
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: $primary-text-color;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
overflow: hidden;
|
||||
max-width: 90vw;
|
||||
width: 480px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
||||
.status__relative-time {
|
||||
order: 2;
|
||||
@media screen and (max-width: $no-columns-breakpoint) {
|
||||
border-bottom: 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.status__content__spoiler-link {
|
||||
color: lighten($secondary-text-color, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.boost-modal .status-direct {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.boost-modal__container {
|
||||
|
@ -6806,6 +6855,7 @@ a.status-card {
|
|||
.report-modal {
|
||||
width: 90vw;
|
||||
max-width: 700px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
.report-dialog-modal {
|
||||
|
@ -7029,7 +7079,7 @@ a.status-card {
|
|||
|
||||
.report-modal__container {
|
||||
display: flex;
|
||||
border-top: 1px solid $ui-secondary-color;
|
||||
border-top: 1px solid var(--background-border-color);
|
||||
|
||||
@media screen and (width <= 480px) {
|
||||
flex-wrap: wrap;
|
||||
|
@ -7087,7 +7137,7 @@ a.status-card {
|
|||
|
||||
.report-modal__comment {
|
||||
padding: 20px;
|
||||
border-inline-end: 1px solid $ui-secondary-color;
|
||||
border-inline-end: 1px solid var(--background-border-color);
|
||||
max-width: 320px;
|
||||
|
||||
p {
|
||||
|
@ -7098,7 +7148,7 @@ a.status-card {
|
|||
|
||||
.setting-text-label {
|
||||
display: block;
|
||||
color: $inverted-text-color;
|
||||
color: $secondary-text-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
|
@ -7164,7 +7214,7 @@ a.status-card {
|
|||
|
||||
li:not(:empty) {
|
||||
a {
|
||||
color: $inverted-text-color;
|
||||
color: $primary-text-color;
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
|
@ -7257,7 +7307,7 @@ a.status-card {
|
|||
|
||||
.compare-history-modal {
|
||||
.report-modal__target {
|
||||
border-bottom: 1px solid $ui-secondary-color;
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
&__container {
|
||||
|
@ -7267,7 +7317,7 @@ a.status-card {
|
|||
}
|
||||
|
||||
.status__content {
|
||||
color: $inverted-text-color;
|
||||
color: $secondary-text-color;
|
||||
font-size: 19px;
|
||||
line-height: 24px;
|
||||
|
||||
|
@ -7357,6 +7407,64 @@ img.modal-warning {
|
|||
inset-inline-start: 8px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
&--layout-2 {
|
||||
.media-gallery__item:nth-child(1) {
|
||||
border-end-end-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(2) {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--layout-3 {
|
||||
.media-gallery__item:nth-child(1) {
|
||||
border-end-end-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(2) {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(3) {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--layout-4 {
|
||||
.media-gallery__item:nth-child(1) {
|
||||
border-end-end-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(2) {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(3) {
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.media-gallery__item:nth-child(4) {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery__alt__label,
|
||||
|
@ -8202,69 +8310,6 @@ noscript {
|
|||
}
|
||||
}
|
||||
|
||||
.embed-modal {
|
||||
width: auto;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
|
||||
h4 {
|
||||
padding: 30px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.embed-modal__container {
|
||||
padding: 10px;
|
||||
|
||||
.hint {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.embed-modal__html {
|
||||
outline: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 10px;
|
||||
font-family: $font-monospace, monospace;
|
||||
background: $ui-base-color;
|
||||
color: $primary-text-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 4px;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner,
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: 0 !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-modal__iframe {
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moved-account-banner,
|
||||
.follow-request-banner,
|
||||
.account-memorial-banner {
|
||||
|
|
|
@ -147,28 +147,6 @@
|
|||
border-top-color: lighten($ui-base-color, 4%);
|
||||
}
|
||||
|
||||
// Change the background colors of modals
|
||||
.actions-modal,
|
||||
.boost-modal,
|
||||
.confirmation-modal,
|
||||
.mute-modal,
|
||||
.block-modal,
|
||||
.report-modal,
|
||||
.report-dialog-modal,
|
||||
.embed-modal,
|
||||
.error-modal,
|
||||
.onboarding-modal,
|
||||
.compare-history-modal,
|
||||
.report-modal__comment .setting-text__wrapper,
|
||||
.report-modal__comment .setting-text,
|
||||
.announcements,
|
||||
.picture-in-picture__header,
|
||||
.picture-in-picture__footer,
|
||||
.reactions-bar__item {
|
||||
background: $white;
|
||||
border: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
.reactions-bar__item:hover,
|
||||
.reactions-bar__item:focus,
|
||||
.reactions-bar__item:active {
|
||||
|
@ -198,14 +176,6 @@
|
|||
color: $white;
|
||||
}
|
||||
|
||||
.report-modal__comment {
|
||||
border-right-color: lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
.report-modal__container {
|
||||
border-top-color: lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
.column-settings__hashtags .column-select__option {
|
||||
color: $white;
|
||||
}
|
||||
|
|
|
@ -1,239 +0,0 @@
|
|||
.activity-stream {
|
||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@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;
|
||||
padding-inline-start: (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 {
|
||||
padding: 8px 0;
|
||||
padding-bottom: 2px;
|
||||
margin: initial;
|
||||
margin-inline-start: 48px + 15px * 2;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
&__prepend-icon-wrapper {
|
||||
position: absolute;
|
||||
margin: initial;
|
||||
float: initial;
|
||||
width: auto;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styling from upstream's WebUI, as public pages use the same layout
|
||||
.embed {
|
||||
.status {
|
||||
.status__info {
|
||||
font-size: 15px;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.status__relative-time {
|
||||
color: $dark-text-color;
|
||||
float: right;
|
||||
font-size: 14px;
|
||||
width: auto;
|
||||
margin: initial;
|
||||
padding: initial;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.status__visibility-icon {
|
||||
padding: 0 4px;
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.status__info .status__display-name {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 6px 0;
|
||||
padding-right: 25px;
|
||||
margin: initial;
|
||||
}
|
||||
|
||||
.status__avatar {
|
||||
height: 48px;
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
margin: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rtl {
|
||||
.embed {
|
||||
.status {
|
||||
padding-left: 10px;
|
||||
padding-right: 68px;
|
||||
|
||||
.status__info .status__display-name {
|
||||
padding-left: 25px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.status__relative-time,
|
||||
.status__visibility-icon {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__content__read-more-button,
|
||||
.status__content__translate-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: $highlight-text-color;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
padding-top: 16px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
|
|||
|
||||
try {
|
||||
regex = new RegExp(skip_unfold_regex.trim(), 'i');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Bad regex, skip filters
|
||||
return true;
|
||||
}
|
||||
|
|
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
|
@ -65,7 +65,7 @@ export const synchronouslySubmitMarkers = createAppAsyncThunk(
|
|||
client.setRequestHeader('Content-Type', 'application/json');
|
||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||
client.send(JSON.stringify(params));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Do not make the BeforeUnload handler error out
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -151,7 +151,7 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) {
|
|||
// TODO: polling for merged notifications
|
||||
try {
|
||||
await dispatch(pollRecentGroupNotifications());
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// TODO
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -5,7 +5,7 @@ export function start() {
|
|||
|
||||
try {
|
||||
Rails.start();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If called twice
|
||||
}
|
||||
}
|
||||
|
|
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { useRef, useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [setAnimationTimeout] = useTimeout();
|
||||
|
||||
const handleInputClick = useCallback(() => {
|
||||
setCopied(false);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
inputRef.current.setSelectionRange(0, value.length);
|
||||
}
|
||||
}, [setCopied, value]);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(value);
|
||||
inputRef.current?.blur();
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== ' ') return;
|
||||
void navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
}, [setFocused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('copy-paste-text', { copied, focused })}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleInputClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
value={value}
|
||||
ref={inputRef}
|
||||
onClick={handleInputClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button className='button' onClick={handleButtonClick}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} />{' '}
|
||||
{copied ? (
|
||||
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='copypaste.copy_to_clipboard'
|
||||
defaultMessage='Copy to clipboard'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
|
|||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} catch {
|
||||
// do nothing
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
|
|
@ -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' />
|
||||
);
|
||||
|
|
|
@ -327,7 +327,7 @@ class MediaGallery extends PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -55,7 +55,7 @@ const messages = defineMessages({
|
|||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
unmuteAccount,
|
||||
unblockAccount,
|
||||
} from '../actions/accounts';
|
||||
import { showAlertForError } from '../actions/alerts';
|
||||
import { initBlockModal } from '../actions/blocks';
|
||||
import {
|
||||
replyCompose,
|
||||
|
@ -100,10 +99,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
onEmbed (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
modalProps: {
|
||||
id: status.get('id'),
|
||||
onError: error => dispatch(showAlertForError(error)),
|
||||
},
|
||||
modalProps: { id: status.get('id') },
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
@ -131,7 +131,7 @@ class LoginForm extends React.PureComponent {
|
|||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch(_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
@ -47,7 +49,7 @@ export const NotificationMention: React.FC<{
|
|||
status.get('visibility') === 'direct',
|
||||
status.get('in_reply_to_account_id') === me,
|
||||
] as const;
|
||||
});
|
||||
}, isEqual);
|
||||
|
||||
let labelRenderer = mentionLabelRenderer;
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
|
@ -62,7 +63,7 @@ export const Notifications: React.FC<{
|
|||
multiColumn?: boolean;
|
||||
}> = ({ columnId, multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const notifications = useAppSelector(selectNotificationGroups);
|
||||
const notifications = useAppSelector(selectNotificationGroups, isEqual);
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
|
||||
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||
|
|
|
@ -10,8 +10,8 @@ import { Link } from 'react-router-dom';
|
|||
import SwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { me, domain } from 'mastodon/initial_state';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
@ -20,67 +20,6 @@ const messages = defineMessages({
|
|||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||
});
|
||||
|
||||
class CopyPasteText extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
focused: false,
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.input = c;
|
||||
};
|
||||
|
||||
handleInputClick = () => {
|
||||
this.setState({ copied: false });
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
this.input.setSelectionRange(0, this.props.value.length);
|
||||
};
|
||||
|
||||
handleButtonClick = e => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.input.blur();
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ focused: false });
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value } = this.props;
|
||||
const { copied, focused } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
|
||||
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
|
||||
|
||||
<button className='button' onClick={this.handleButtonClick}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TipCarousel extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
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>
|
||||
);
|
||||
};
|
|
@ -49,7 +49,7 @@ const messages = defineMessages({
|
|||
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -71,7 +71,10 @@ export const ConfirmationModal: React.FC<
|
|||
/>
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>{confirm}</Button>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */}
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
{confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -88,7 +88,7 @@ export const DomainBlockModal = ({ domain, accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import api from 'mastodon/api';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class EmbedModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: false,
|
||||
oembed: null,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { id } = this.props;
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
api().get(`/api/web/embeds/${id}`).then(res => {
|
||||
this.setState({ loading: false, oembed: res.data });
|
||||
|
||||
const iframeDocument = this.iframe.contentWindow.document;
|
||||
|
||||
iframeDocument.open();
|
||||
iframeDocument.write(res.data.html);
|
||||
iframeDocument.close();
|
||||
|
||||
iframeDocument.body.style.margin = 0;
|
||||
this.iframe.width = iframeDocument.body.scrollWidth;
|
||||
this.iframe.height = iframeDocument.body.scrollHeight;
|
||||
}).catch(error => {
|
||||
this.props.onError(error);
|
||||
});
|
||||
}
|
||||
|
||||
setIframeRef = c => {
|
||||
this.iframe = c;
|
||||
};
|
||||
|
||||
handleTextareaClick = (e) => {
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, onClose } = this.props;
|
||||
const { oembed } = this.state;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal report-modal embed-modal'>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={16} />
|
||||
<FormattedMessage id='status.embed' defaultMessage='Embed' />
|
||||
</div>
|
||||
|
||||
<div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
|
||||
<p className='hint'>
|
||||
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
|
||||
</p>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='embed-modal__html'
|
||||
readOnly
|
||||
value={oembed && oembed.html || ''}
|
||||
onClick={this.handleTextareaClick}
|
||||
/>
|
||||
|
||||
<p className='hint'>
|
||||
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
|
||||
</p>
|
||||
|
||||
<iframe
|
||||
className='embed-modal__iframe'
|
||||
frameBorder='0'
|
||||
ref={this.setIframeRef}
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='preview'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(EmbedModal);
|
116
app/javascript/mastodon/features/ui/components/embed_modal.tsx
Normal file
116
app/javascript/mastodon/features/ui/components/embed_modal.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { showAlertForError } from 'mastodon/actions/alerts';
|
||||
import api from 'mastodon/api';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
interface OEmbedResponse {
|
||||
html: string;
|
||||
}
|
||||
|
||||
const EmbedModal: React.FC<{
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
}> = ({ id, onClose }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const [oembed, setOembed] = useState<OEmbedResponse | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
api()
|
||||
.get(`/api/web/embeds/${id}`)
|
||||
.then((res) => {
|
||||
const data = res.data as OEmbedResponse;
|
||||
|
||||
setOembed(data);
|
||||
|
||||
const iframeDocument = iframeRef.current?.contentWindow?.document;
|
||||
|
||||
if (!iframeDocument) {
|
||||
return '';
|
||||
}
|
||||
|
||||
iframeDocument.open();
|
||||
iframeDocument.write(data.html);
|
||||
iframeDocument.close();
|
||||
|
||||
iframeDocument.body.style.margin = '0px';
|
||||
|
||||
// This is our best chance to ensure the parent iframe has the correct height...
|
||||
intervalRef.current = setInterval(
|
||||
() =>
|
||||
window.requestAnimationFrame(() => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.width = `${iframeDocument.body.scrollWidth}px`;
|
||||
iframeRef.current.height = `${iframeDocument.body.scrollHeight}px`;
|
||||
}
|
||||
}),
|
||||
100,
|
||||
);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
dispatch(showAlertForError(error));
|
||||
});
|
||||
}, [dispatch, id, setOembed]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id='report.close' defaultMessage='Done' />
|
||||
</Button>
|
||||
<span className='dialog-modal__header__title'>
|
||||
<FormattedMessage id='status.embed' defaultMessage='Get embed code' />
|
||||
</span>
|
||||
<Button secondary onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div className='dialog-modal__content__form'>
|
||||
<FormattedMessage
|
||||
id='embed.instructions'
|
||||
defaultMessage='Embed this status on your website by copying the code below.'
|
||||
/>
|
||||
|
||||
<CopyPasteText value={oembed?.html ?? ''} />
|
||||
|
||||
<FormattedMessage
|
||||
id='embed.preview'
|
||||
defaultMessage='Here is what it will look like:'
|
||||
/>
|
||||
|
||||
<iframe
|
||||
frameBorder='0'
|
||||
ref={iframeRef}
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
title='Preview'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default EmbedModal;
|
|
@ -137,7 +137,7 @@ export const MuteModal = ({ accountId, acct }) => {
|
|||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleClick}>
|
||||
<Button onClick={handleClick} autoFocus>
|
||||
<FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -3,10 +3,12 @@ import { connect } from 'react-redux';
|
|||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
||||
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
||||
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
|
||||
props: state.getIn(['modal', 'stack', 0, 'modalProps'], defaultProps),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -4,24 +4,11 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { NotificationStack } from 'react-notification';
|
||||
|
||||
import { dismissAlert } from '../../../actions/alerts';
|
||||
import { getAlerts } from '../../../selectors';
|
||||
|
||||
const formatIfNeeded = (intl, message, values) => {
|
||||
if (typeof message === 'object') {
|
||||
return intl.formatMessage(message, values);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import { getAlerts } from 'mastodon/selectors';
|
||||
|
||||
const mapStateToProps = (state, { intl }) => ({
|
||||
notifications: getAlerts(state).map(alert => ({
|
||||
...alert,
|
||||
action: formatIfNeeded(intl, alert.action, alert.values),
|
||||
title: formatIfNeeded(intl, alert.title, alert.values),
|
||||
message: formatIfNeeded(intl, alert.message, alert.values),
|
||||
})),
|
||||
notifications: getAlerts(state, { intl }),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
|
|
@ -319,8 +319,8 @@ class UI extends PureComponent {
|
|||
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} catch (err) {
|
||||
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
@ -308,7 +308,6 @@
|
|||
"lists.search": "Buscar entre la chent a la quala sigues",
|
||||
"lists.subheading": "Las tuyas listas",
|
||||
"load_pending": "{count, plural, one {# nuevo elemento} other {# nuevos elementos}}",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Amaga la imachen} other {Amaga las imáchens}}",
|
||||
"moved_to_account_banner.text": "La tuya cuenta {disabledAccount} ye actualment deshabilitada perque t'has mudau a {movedToAccount}.",
|
||||
"navigation_bar.about": "Sobre",
|
||||
"navigation_bar.blocks": "Usuarios blocaus",
|
||||
|
|
|
@ -443,7 +443,6 @@
|
|||
"lists.subheading": "قوائمك",
|
||||
"load_pending": "{count, plural, one {# عنصر جديد} other {# عناصر جديدة}}",
|
||||
"loading_indicator.label": "جاري التحميل…",
|
||||
"media_gallery.toggle_visible": "{number, plural, zero {} one {اخف الصورة} two {اخف الصورتين} few {اخف الصور} many {اخف الصور} other {اخف الصور}}",
|
||||
"moved_to_account_banner.text": "حسابك {disabledAccount} معطل حاليًا لأنك انتقلت إلى {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "إخفاء من قائمة الإشعارات",
|
||||
"mute_modal.hide_options": "إخفاء الخيارات",
|
||||
|
|
|
@ -268,7 +268,6 @@
|
|||
"lists.search": "Buscar ente los perfiles que sigues",
|
||||
"lists.subheading": "Les tos llistes",
|
||||
"load_pending": "{count, plural, one {# elementu nuevu} other {# elementos nuevos}}",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Anubrir la imaxe} other {Anubrir les imáxenes}}",
|
||||
"navigation_bar.about": "Tocante a",
|
||||
"navigation_bar.blocks": "Perfiles bloquiaos",
|
||||
"navigation_bar.bookmarks": "Marcadores",
|
||||
|
|
|
@ -437,7 +437,6 @@
|
|||
"lists.subheading": "Вашыя спісы",
|
||||
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
|
||||
"loading_indicator.label": "Загрузка…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Схаваць відарыс} other {Схаваць відарысы}}",
|
||||
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
|
||||
"mute_modal.hide_options": "Схаваць опцыі",
|
||||
|
|
|
@ -444,7 +444,6 @@
|
|||
"lists.subheading": "Вашите списъци",
|
||||
"load_pending": "{count, plural, one {# нов елемент} other {# нови елемента}}",
|
||||
"loading_indicator.label": "Зареждане…",
|
||||
"media_gallery.toggle_visible": "Скриване на {number, plural, one {изображение} other {изображения}}",
|
||||
"moved_to_account_banner.text": "Вашият акаунт {disabledAccount} сега е изключен, защото се преместихте в {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Скриване от известията",
|
||||
"mute_modal.hide_options": "Скриване на възможностите",
|
||||
|
|
|
@ -288,7 +288,6 @@
|
|||
"lists.search": "যাদের অনুসরণ করেন তাদের ভেতরে খুঁজুন",
|
||||
"lists.subheading": "আপনার তালিকা",
|
||||
"load_pending": "{count, plural, one {# নতুন জিনিস} other {# নতুন জিনিস}}",
|
||||
"media_gallery.toggle_visible": "দৃশ্যতার অবস্থা বদলান",
|
||||
"navigation_bar.about": "পরিচিতি",
|
||||
"navigation_bar.blocks": "বন্ধ করা ব্যবহারকারী",
|
||||
"navigation_bar.bookmarks": "বুকমার্ক",
|
||||
|
|
|
@ -360,7 +360,6 @@
|
|||
"lists.subheading": "Ho listennoù",
|
||||
"load_pending": "{count, plural, one {# dra nevez} other {# dra nevez}}",
|
||||
"loading_indicator.label": "O kargañ…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Kuzhat ar skeudenn} other {Kuzhat ar skeudenn}}",
|
||||
"navigation_bar.about": "Diwar-benn",
|
||||
"navigation_bar.blocks": "Implijer·ezed·ien berzet",
|
||||
"navigation_bar.bookmarks": "Sinedoù",
|
||||
|
|
|
@ -457,7 +457,7 @@
|
|||
"lists.subheading": "Les teves llistes",
|
||||
"load_pending": "{count, plural, one {# element nou} other {# elements nous}}",
|
||||
"loading_indicator.label": "Es carrega…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Amaga la imatge} other {Amaga les imatges}}",
|
||||
"media_gallery.hide": "Amaga",
|
||||
"moved_to_account_banner.text": "El teu compte {disabledAccount} està desactivat perquè l'has mogut a {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Amaga de les notificacions",
|
||||
"mute_modal.hide_options": "Amaga les opcions",
|
||||
|
|
|
@ -355,7 +355,6 @@
|
|||
"lists.search": "بگەڕێ لەناو ئەو کەسانەی کە شوێنیان کەوتویت",
|
||||
"lists.subheading": "لیستەکانت",
|
||||
"load_pending": "{count, plural, one {# بەڕگەی نوێ} other {# بەڕگەی نوێ}}",
|
||||
"media_gallery.toggle_visible": "شاردنەوەی {number, plural, one {image} other {images}}",
|
||||
"moved_to_account_banner.text": "ئەکاونتەکەت {disabledAccount} لە ئێستادا لەکارخراوە چونکە تۆ چوویتە {movedToAccount}.",
|
||||
"navigation_bar.about": "دەربارە",
|
||||
"navigation_bar.blocks": "بەکارهێنەرە بلۆککراوەکان",
|
||||
|
|
|
@ -214,7 +214,6 @@
|
|||
"lists.search": "Circà indè i vostr'abbunamenti",
|
||||
"lists.subheading": "E vo liste",
|
||||
"load_pending": "{count, plural, one {# entrata nova} other {# entrate nove}}",
|
||||
"media_gallery.toggle_visible": "Piattà {number, plural, one {ritrattu} other {ritratti}}",
|
||||
"navigation_bar.blocks": "Utilizatori bluccati",
|
||||
"navigation_bar.bookmarks": "Segnalibri",
|
||||
"navigation_bar.community_timeline": "Linea pubblica lucale",
|
||||
|
|
|
@ -435,7 +435,6 @@
|
|||
"lists.subheading": "Vaše seznamy",
|
||||
"load_pending": "{count, plural, one {# nová položka} few {# nové položky} many {# nových položek} other {# nových položek}}",
|
||||
"loading_indicator.label": "Načítání…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Skrýt obrázek} few {Skrýt obrázky} many {Skrýt obrázky} other {Skrýt obrázky}}",
|
||||
"moved_to_account_banner.text": "Váš účet {disabledAccount} je momentálně deaktivován, protože jste se přesunul/a na {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Skrýt z notifikací",
|
||||
"mute_modal.hide_options": "Skrýt možnosti",
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
"block_modal.title": "Blocio defnyddiwr?",
|
||||
"block_modal.you_wont_see_mentions": "Fyddwch chi ddim yn gweld postiadau sy'n sôn amdanyn nhw.",
|
||||
"boost_modal.combo": "Mae modd pwyso {combo} er mwyn hepgor hyn tro nesa",
|
||||
"boost_modal.reblog": "Hybu postiad",
|
||||
"boost_modal.reblog": "Hybu postiad?",
|
||||
"boost_modal.undo_reblog": "Dad-hybu postiad?",
|
||||
"bundle_column_error.copy_stacktrace": "Copïo'r adroddiad gwall",
|
||||
"bundle_column_error.error.body": "Nid oedd modd cynhyrchu'r dudalen honno. Gall fod oherwydd gwall yn ein cod neu fater cydnawsedd porwr.",
|
||||
|
@ -457,7 +457,7 @@
|
|||
"lists.subheading": "Eich rhestrau",
|
||||
"load_pending": "{count, plural, one {# eitem newydd} other {# eitem newydd}}",
|
||||
"loading_indicator.label": "Yn llwytho…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Cuddio delwedd} other {Cuddio delwedd}}",
|
||||
"media_gallery.hide": "Cuddio",
|
||||
"moved_to_account_banner.text": "Ar hyn y bryd, mae eich cyfrif {disabledAccount} wedi ei analluogi am i chi symud i {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Cuddio rhag hysbysiadau",
|
||||
"mute_modal.hide_options": "Cuddio'r dewis",
|
||||
|
@ -780,6 +780,7 @@
|
|||
"status.bookmark": "Llyfrnodi",
|
||||
"status.cancel_reblog_private": "Dadhybu",
|
||||
"status.cannot_reblog": "Nid oes modd hybu'r postiad hwn",
|
||||
"status.continued_thread": "Edefyn parhaus",
|
||||
"status.copy": "Copïo dolen i'r post",
|
||||
"status.delete": "Dileu",
|
||||
"status.detailed_status": "Golwg manwl o'r sgwrs",
|
||||
|
@ -813,6 +814,7 @@
|
|||
"status.reblogs.empty": "Does neb wedi hybio'r post yma eto. Pan y bydd rhywun yn gwneud, byddent yn ymddangos yma.",
|
||||
"status.redraft": "Dileu ac ailddrafftio",
|
||||
"status.remove_bookmark": "Tynnu nod tudalen",
|
||||
"status.replied_in_thread": "Atebodd mewn edefyn",
|
||||
"status.replied_to": "Wedi ateb {name}",
|
||||
"status.reply": "Ateb",
|
||||
"status.replyAll": "Ateb i edefyn",
|
||||
|
|
|
@ -457,7 +457,7 @@
|
|||
"lists.subheading": "Dine lister",
|
||||
"load_pending": "{count, plural, one {# nyt emne} other {# nye emner}}",
|
||||
"loading_indicator.label": "Indlæser…",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Skjul billede} other {Skjul billeder}}",
|
||||
"media_gallery.hide": "Skjul",
|
||||
"moved_to_account_banner.text": "Din konto {disabledAccount} er pt. deaktiveret, da du flyttede til {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Skjul fra notifikationer",
|
||||
"mute_modal.hide_options": "Skjul valgmuligheder",
|
||||
|
|
|
@ -457,7 +457,7 @@
|
|||
"lists.subheading": "Deine Listen",
|
||||
"load_pending": "{count, plural, one {# neuer Beitrag} other {# neue Beiträge}}",
|
||||
"loading_indicator.label": "Wird geladen …",
|
||||
"media_gallery.toggle_visible": "{number, plural, one {Medium ausblenden} other {Medien ausblenden}}",
|
||||
"media_gallery.hide": "Ausblenden",
|
||||
"moved_to_account_banner.text": "Dein Konto {disabledAccount} ist derzeit deaktiviert, weil du zu {movedToAccount} umgezogen bist.",
|
||||
"mute_modal.hide_from_notifications": "Benachrichtigungen ausblenden",
|
||||
"mute_modal.hide_options": "Einstellungen ausblenden",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue