mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +00:00
Merge upstream changes up to df72a2dbbe (#3202)
* Refactor emoji GIF animation (#36165) * New Crowdin Translations (automated) (#36228) Co-authored-by: GitHub Actions <noreply@github.com> * Update to puma 7 (#36238) * Fix unfortunate action button wrapping in admin area (#36247) * Remove the `outgoing_quotes` feature flag, making the feature unconditional (#36130) * New Crowdin Translations (automated) (#36246) Co-authored-by: GitHub Actions <noreply@github.com> * Highlight newly added replies in thread view (#36237) * Fix missed event handler (#36248) * Implement new design for "Refetch all" (#36172) * Fix Private Messages self-quoting private posts being changed to followers-only (#36249) * [Glitch] Refactor emoji GIF animation Port6bd90940b6to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * [Glitch] Fix unfortunate action button wrapping in admin area Port6cbc857ee0to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * [Glitch] Remove the `outgoing_quotes` feature flag, making the feature unconditional Porte1f7847b64to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * [Glitch] Highlight newly added replies in thread view Port059bf1e980to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * [Glitch] Fix missed event handler Port29d9f81e42to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * [Glitch] Implement new design for "Refetch all" Port3a81ee8f5bto glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> * Fix newly-highlighted replies not being interactable (#36256) * [Glitch] Fix newly-highlighted replies not being interactable Portdf72a2dbbeto glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com> --------- Signed-off-by: Claire <claire.github-309c@sitedethib.com> Co-authored-by: Echo <ChaosExAnima@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: GitHub Actions <noreply@github.com> Co-authored-by: David Roetzel <david@roetzel.de> Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -4,7 +4,7 @@ source 'https://rubygems.org'
|
||||
ruby '>= 3.2.0', '< 3.5.0'
|
||||
|
||||
gem 'propshaft'
|
||||
gem 'puma', '~> 6.3'
|
||||
gem 'puma', '~> 7.0'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'thor', '~> 1.2'
|
||||
|
||||
|
||||
@@ -637,7 +637,7 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.1)
|
||||
puma (7.0.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.1)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -1052,7 +1052,7 @@ DEPENDENCIES
|
||||
prometheus_exporter (~> 2.2)
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (~> 6.3)
|
||||
puma (~> 7.0)
|
||||
pundit (~> 2.3)
|
||||
rack-attack (~> 6.6)
|
||||
rack-cors
|
||||
|
||||
@@ -4,7 +4,6 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
||||
include Api::InteractionPoliciesConcern
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||
before_action -> { check_feature_enabled }
|
||||
|
||||
def update
|
||||
authorize @status, :update?
|
||||
@@ -22,10 +21,6 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
||||
params.permit(:quote_approval_policy)
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||
end
|
||||
|
||||
def broadcast_updates!
|
||||
DistributionWorker.perform_async(@status.id, { 'update' => true })
|
||||
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
||||
|
||||
@@ -159,8 +159,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
end
|
||||
|
||||
def set_quoted_status
|
||||
return unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||
|
||||
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
||||
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
|
||||
@@ -4,8 +4,6 @@ module Api::InteractionPoliciesConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def quote_approval_policy
|
||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||
|
||||
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||
when 'public'
|
||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||
|
||||
@@ -8,6 +8,7 @@ const meta = {
|
||||
component: Alert,
|
||||
args: {
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
animateFrom: 'side',
|
||||
title: '',
|
||||
message: '',
|
||||
@@ -20,6 +21,12 @@ const meta = {
|
||||
type: 'boolean',
|
||||
description: 'Animate to the active (displayed) state of the alert',
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Display a loading indicator in the alert, replacing the dismiss button if present',
|
||||
},
|
||||
animateFrom: {
|
||||
control: 'radio',
|
||||
type: 'string',
|
||||
@@ -108,3 +115,11 @@ export const InSizedContainer: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLoadingIndicator: Story = {
|
||||
args: {
|
||||
...WithDismissButton.args,
|
||||
isLoading: true,
|
||||
},
|
||||
render: InSizedContainer.render,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
@@ -10,21 +11,23 @@ import { IconButton } from '../icon_button';
|
||||
* Snackbar/Toast-style notification component.
|
||||
*/
|
||||
export const Alert: React.FC<{
|
||||
isActive?: boolean;
|
||||
animateFrom?: 'side' | 'below';
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
onActionClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
isActive?: boolean;
|
||||
isLoading?: boolean;
|
||||
animateFrom?: 'side' | 'below';
|
||||
}> = ({
|
||||
isActive,
|
||||
animateFrom = 'side',
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
onActionClick,
|
||||
onDismiss,
|
||||
isActive,
|
||||
isLoading,
|
||||
animateFrom = 'side',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -51,7 +54,13 @@ export const Alert: React.FC<{
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDismiss && (
|
||||
{isLoading && (
|
||||
<span className='notification-bar__loading-indicator'>
|
||||
<LoadingIndicator />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onDismiss && !isLoading && (
|
||||
<IconButton
|
||||
title={intl.formatMessage({
|
||||
id: 'dismissable_banner.dismiss',
|
||||
|
||||
@@ -14,7 +14,10 @@ export const DisplayNameWithoutDomain: FC<
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, className, children, ...props }) => {
|
||||
return (
|
||||
<span {...props} className={classNames('display-name', className)}>
|
||||
<span
|
||||
{...props}
|
||||
className={classNames('display-name animate-parent', className)}
|
||||
>
|
||||
<bdi>
|
||||
{account ? (
|
||||
<EmojiHTML
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A helper component for managing the rendering of components that
|
||||
* need to stay in the DOM a bit longer to finish their CSS exit animation.
|
||||
*
|
||||
* In the future, replace this component with plain CSS once that is feasible.
|
||||
* This will require broader support for `transition-behavior: allow-discrete`
|
||||
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
|
||||
*/
|
||||
export const ExitAnimationWrapper: React.FC<{
|
||||
/**
|
||||
* Set this to true to indicate that the nested component should be rendered
|
||||
*/
|
||||
isActive: boolean;
|
||||
/**
|
||||
* How long the component should be rendered after `isActive` was set to `false`
|
||||
*/
|
||||
delayMs?: number;
|
||||
/**
|
||||
* Set this to true to also delay the entry of the nested component until after
|
||||
* another one has exited full.
|
||||
*/
|
||||
withEntryDelay?: boolean;
|
||||
/**
|
||||
* Render prop that provides the nested component with the `delayedIsActive` flag
|
||||
*/
|
||||
children: (delayedIsActive: boolean) => React.ReactNode;
|
||||
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
|
||||
const [delayedIsActive, setDelayedIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && !withEntryDelay) {
|
||||
setDelayedIsActive(true);
|
||||
|
||||
return () => '';
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
setDelayedIsActive(isActive);
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [isActive, delayMs, withEntryDelay]);
|
||||
|
||||
if (!isActive && !delayedIsActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children(isActive && delayedIsActive);
|
||||
};
|
||||
@@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent {
|
||||
prepend: PropTypes.string,
|
||||
withDismiss: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
shouldHighlightOnMount: PropTypes.bool,
|
||||
getScrollPosition: PropTypes.func,
|
||||
updateScrollBottom: PropTypes.func,
|
||||
expanded: PropTypes.bool,
|
||||
@@ -705,6 +706,7 @@ class Status extends ImmutablePureComponent {
|
||||
muted: this.props.muted,
|
||||
'status--is-quote': isQuotedPost,
|
||||
'status--has-quote': !!status.get('quote'),
|
||||
'status--highlighted-entry': this.props.shouldHighlightOnMount,
|
||||
})
|
||||
}
|
||||
data-id={status.get('id')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||
import { statusFactoryState } from '@/testing/factories';
|
||||
|
||||
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||
import { BoostButton } from './boost_button';
|
||||
|
||||
interface StoryProps {
|
||||
visibility: StatusVisibility;
|
||||
@@ -38,10 +38,7 @@ const meta = {
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<StatusBoostButton
|
||||
status={argsToStatus(args)}
|
||||
counters={args.reblogCount > 0}
|
||||
/>
|
||||
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
|
||||
),
|
||||
} satisfies Meta<StoryProps>;
|
||||
|
||||
@@ -78,12 +75,3 @@ export const Mine: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Legacy: Story = {
|
||||
render: (args) => (
|
||||
<LegacyReblogButton
|
||||
status={argsToStatus(args)}
|
||||
counters={args.reblogCount > 0}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||
import type { Status } from '@/flavours/glitch/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||
import type { SomeRequired } from '@/flavours/glitch/utils/types';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
@@ -47,10 +46,7 @@ interface ReblogButtonProps {
|
||||
|
||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||
|
||||
export const StatusBoostButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const statusState = useAppSelector((state) =>
|
||||
@@ -192,65 +188,3 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Legacy helpers
|
||||
|
||||
// Switch between the legacy and new reblog button based on feature flag.
|
||||
export const BoostButton: FC<ReblogButtonProps> = (props) => {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
return <StatusBoostButton {...props} />;
|
||||
}
|
||||
return <LegacyReblogButton {...props} />;
|
||||
};
|
||||
|
||||
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() => boostItemState(statusState),
|
||||
[statusState],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (statusState.isLoggedIn) {
|
||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, statusState.isLoggedIn],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
active={!!status.get('reblogged')}
|
||||
title={intl.formatMessage(meta ?? title)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
counter={
|
||||
counters
|
||||
? (status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ import { me } from '../../initial_state';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
import { RelativeTimestamp } from '../relative_timestamp';
|
||||
import { isFeatureEnabled } from '../../utils/environment';
|
||||
import { BoostButton } from '../status/boost_button';
|
||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||
|
||||
@@ -254,7 +253,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
if (writtenByMe || withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
@@ -237,32 +237,6 @@ class StatusContent extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._updateStatusLinks();
|
||||
}
|
||||
@@ -354,7 +328,13 @@ class StatusContent extends PureComponent {
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div
|
||||
className={classNames}
|
||||
ref={this.setRef}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
key='status-content'
|
||||
>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
@@ -371,7 +351,7 @@ class StatusContent extends PureComponent {
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className={classNames} ref={this.setRef}>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
import Status from 'flavours/glitch/components/status';
|
||||
import { deleteModal } from 'flavours/glitch/initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import { isFeatureEnabled } from 'flavours/glitch/utils/environment';
|
||||
|
||||
import { setStatusQuotePolicy } from '../actions/statuses_typed';
|
||||
|
||||
@@ -85,9 +84,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||
},
|
||||
|
||||
onQuote (status) {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
}
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
|
||||
@@ -383,36 +383,6 @@ export const AccountHeader: React.FC<{
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-original') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-static') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
@@ -812,11 +782,9 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames('account__header', {
|
||||
className={classNames('account__header animate-parent', {
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
|
||||
@@ -12,14 +12,12 @@ import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||
import { Icon } from '@/flavours/glitch/components/icon';
|
||||
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
|
||||
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
|
||||
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
|
||||
import { messages as privacyMessages } from './privacy_dropdown';
|
||||
|
||||
@@ -43,9 +41,6 @@ interface PrivacyDropdownProps {
|
||||
}
|
||||
|
||||
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
||||
if (!isFeatureEnabled('outgoing_quotes')) {
|
||||
return <PrivacyDropdownContainer {...props} />;
|
||||
}
|
||||
return <PrivacyModalButton {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||
import StatusContent from 'flavours/glitch/components/status_content';
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||
import { LinkedDisplayName } from '@/flavours/glitch/components/display_name';
|
||||
|
||||
@@ -61,32 +60,6 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
||||
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
|
||||
const [expanded, setExpanded] = useState(undefined);
|
||||
|
||||
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (unread) {
|
||||
dispatch(markConversationRead(id));
|
||||
@@ -171,7 +144,7 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||
</div>
|
||||
|
||||
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<div className='conversation__content__names animate-parent'>
|
||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
@@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const original = emoji.getAttribute('data-original');
|
||||
if (original) emoji.src = original;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const staticUrl = emoji.getAttribute('data-static');
|
||||
if (staticUrl) emoji.src = staticUrl;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) return;
|
||||
|
||||
@@ -189,9 +155,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
|
||||
{account.get('note').length > 0 && (
|
||||
<div
|
||||
className='account-card__bio translate'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className='account-card__bio translate animate-parent'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||
|
||||
import { useEmojify } from './hooks';
|
||||
@@ -7,12 +9,13 @@ import type { CustomEmojiMapArg } from './types';
|
||||
|
||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'dangerouslySetInnerHTML'
|
||||
'dangerouslySetInnerHTML' | 'className'
|
||||
> & {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
as?: Element;
|
||||
shallow?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ModernEmojiHTML = ({
|
||||
@@ -20,6 +23,7 @@ export const ModernEmojiHTML = ({
|
||||
htmlString,
|
||||
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||
shallow,
|
||||
className = '',
|
||||
...props
|
||||
}: EmojiHTMLProps<ElementType>) => {
|
||||
const emojifiedHtml = useEmojify({
|
||||
@@ -33,7 +37,11 @@ export const ModernEmojiHTML = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||
<Wrapper
|
||||
{...props}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +51,13 @@ export const EmojiHTML = <Element extends ElementType>(
|
||||
if (isModernEmojiEnabled()) {
|
||||
return <ModernEmojiHTML {...props} />;
|
||||
}
|
||||
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
|
||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
61
app/javascript/flavours/glitch/features/emoji/handlers.ts
Normal file
61
app/javascript/flavours/glitch/features/emoji/handlers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||
|
||||
const PARENT_MAX_DEPTH = 10;
|
||||
|
||||
export function handleAnimateGif(event: MouseEvent) {
|
||||
// We already check this in ui/index.jsx, but just to be sure.
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, type } = event;
|
||||
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
setAnimateGif(target, animate);
|
||||
} else if (!(target instanceof HTMLElement) || target === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parent: HTMLElement | null = null;
|
||||
let iter = 0;
|
||||
|
||||
if (target.classList.contains('animate-parent')) {
|
||||
parent = target;
|
||||
} else {
|
||||
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
|
||||
let current: HTMLElement | null = target;
|
||||
while (current) {
|
||||
if (iter >= PARENT_MAX_DEPTH) {
|
||||
return; // We can just exit right now.
|
||||
}
|
||||
current = current.parentElement;
|
||||
if (current?.classList.contains('animate-parent')) {
|
||||
parent = current;
|
||||
break;
|
||||
}
|
||||
iter++;
|
||||
}
|
||||
}
|
||||
|
||||
// Affect all animated children within the parent.
|
||||
if (parent) {
|
||||
const animatedChildren =
|
||||
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
for (const child of animatedChildren) {
|
||||
setAnimateGif(child, animate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAnimateGif(image: HTMLImageElement, animate: boolean) {
|
||||
const { classList, dataset } = image;
|
||||
if (
|
||||
!classList.contains('custom-emoji') ||
|
||||
!dataset.static ||
|
||||
!dataset.original
|
||||
) {
|
||||
return;
|
||||
}
|
||||
image.src = animate ? dataset.original : dataset.static;
|
||||
}
|
||||
@@ -111,42 +111,14 @@ class ContentWithRouter extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { announcement } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='announcements__item__content translate'
|
||||
className='announcements__item__content translate animate-parent'
|
||||
ref={this.setRef}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||
<animated.button
|
||||
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||
onClick={this.handleClick}
|
||||
title={`:${shortCode}:`}
|
||||
style={this.props.style}
|
||||
// This does not use animate-parent as this component is directly rendered by React.
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
<span className='reactions-bar__item__emoji'>
|
||||
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
|
||||
</span>
|
||||
<span className='reactions-bar__item__count'>
|
||||
<AnimatedNumber value={reaction.get('count')} />
|
||||
</span>
|
||||
</animated.button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { isFeatureEnabled } from 'flavours/glitch/utils/environment';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||
@@ -63,12 +62,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
<td><kbd>b</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||
</tr>
|
||||
{isFeatureEnabled('outgoing_quotes') && (
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>d</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td>
|
||||
|
||||
@@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
[clickCoordinatesRef, statusId, account, history],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-original');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-static');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentWarningClick = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
@@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='notification-group__embedded-status'
|
||||
className='notification-group__embedded-status animate-parent'
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className='notification-group__embedded-status__account'>
|
||||
<Avatar account={account} size={16} />
|
||||
|
||||
@@ -20,7 +20,6 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||
import { me } from '../../../initial_state';
|
||||
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||
import { BoostButton } from '@/flavours/glitch/components/status/boost_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -199,7 +198,7 @@ class ActionBar extends PureComponent {
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
if (!['private', 'direct'].includes(status.get('visibility'))) {
|
||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import {
|
||||
fetchContext,
|
||||
@@ -8,31 +8,80 @@ import {
|
||||
} from 'flavours/glitch/actions/statuses';
|
||||
import type { AsyncRefreshHeader } from 'flavours/glitch/api';
|
||||
import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes';
|
||||
import { Alert } from 'flavours/glitch/components/alert';
|
||||
import { ExitAnimationWrapper } from 'flavours/glitch/components/exit_animation_wrapper';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const AnimatedAlert: React.FC<
|
||||
React.ComponentPropsWithoutRef<typeof Alert> & { withEntryDelay?: boolean }
|
||||
> = ({ isActive = false, withEntryDelay, ...props }) => (
|
||||
<ExitAnimationWrapper withEntryDelay isActive={isActive}>
|
||||
{(delayedIsActive) => <Alert isActive={delayedIsActive} {...props} />}
|
||||
</ExitAnimationWrapper>
|
||||
);
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
moreFound: {
|
||||
id: 'status.context.more_replies_found',
|
||||
defaultMessage: 'More replies found',
|
||||
},
|
||||
show: {
|
||||
id: 'status.context.show',
|
||||
defaultMessage: 'Show',
|
||||
},
|
||||
loadingInitial: {
|
||||
id: 'status.context.loading',
|
||||
defaultMessage: 'Checking for more replies',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
loadingMore: {
|
||||
id: 'status.context.loading_more',
|
||||
defaultMessage: 'Loading more replies',
|
||||
},
|
||||
success: {
|
||||
id: 'status.context.loading_success',
|
||||
defaultMessage: 'All replies loaded',
|
||||
},
|
||||
error: {
|
||||
id: 'status.context.loading_error',
|
||||
defaultMessage: "Couldn't load new replies",
|
||||
},
|
||||
retry: {
|
||||
id: 'status.context.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
});
|
||||
|
||||
type LoadingState =
|
||||
| 'idle'
|
||||
| 'more-available'
|
||||
| 'loading-initial'
|
||||
| 'loading-more'
|
||||
| 'success'
|
||||
| 'error';
|
||||
|
||||
export const RefreshController: React.FC<{
|
||||
statusId: string;
|
||||
}> = ({ statusId }) => {
|
||||
const refresh = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
);
|
||||
const autoRefresh = useAppSelector(
|
||||
(state) =>
|
||||
!state.contexts.replies[statusId] ||
|
||||
state.contexts.replies[statusId].length === 0,
|
||||
const currentReplyCount = useAppSelector(
|
||||
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
||||
);
|
||||
const autoRefresh = !currentReplyCount;
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
||||
);
|
||||
|
||||
const [wasDismissed, setWasDismissed] = useState(false);
|
||||
const dismissPrompt = useCallback(() => {
|
||||
setWasDismissed(true);
|
||||
setLoadingState('idle');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
@@ -45,67 +94,104 @@ export const RefreshController: React.FC<{
|
||||
|
||||
if (result.async_refresh.result_count > 0) {
|
||||
if (autoRefresh) {
|
||||
void dispatch(fetchContext({ statusId }));
|
||||
return '';
|
||||
void dispatch(fetchContext({ statusId })).then(() => {
|
||||
setLoadingState('idle');
|
||||
});
|
||||
} else {
|
||||
setLoadingState('more-available');
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
} else {
|
||||
setLoadingState('idle');
|
||||
}
|
||||
} else {
|
||||
scheduleRefresh(refresh);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
if (refresh) {
|
||||
if (refresh && !wasDismissed) {
|
||||
scheduleRefresh(refresh);
|
||||
setLoadingState('loading-initial');
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [dispatch, setReady, statusId, refresh, autoRefresh]);
|
||||
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide success message after a short delay
|
||||
if (loadingState === 'success') {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setLoadingState('idle');
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
return () => '';
|
||||
}, [loadingState]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoading(true);
|
||||
setReady(false);
|
||||
setLoadingState('loading-more');
|
||||
|
||||
dispatch(fetchContext({ statusId }))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
setLoadingState('success');
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
setLoadingState('error');
|
||||
});
|
||||
}, [dispatch, setReady, statusId]);
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (ready && !loading) {
|
||||
if (loadingState === 'loading-initial') {
|
||||
return (
|
||||
<button className='load-more load-gap' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='status.context.load_new_replies'
|
||||
defaultMessage='New replies available'
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loadingInitial)}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!refresh && !loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
<div className='column__alert' role='status' aria-live='polite'>
|
||||
<AnimatedAlert
|
||||
isActive={loadingState === 'more-available'}
|
||||
message={intl.formatMessage(messages.moreFound)}
|
||||
action={intl.formatMessage(messages.show)}
|
||||
onActionClick={handleClick}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
isLoading
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'loading-more'}
|
||||
message={intl.formatMessage(messages.loadingMore)}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'error'}
|
||||
message={intl.formatMessage(messages.error)}
|
||||
action={intl.formatMessage(messages.retry)}
|
||||
onActionClick={handleClick}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'success'}
|
||||
message={intl.formatMessage(messages.success)}
|
||||
animateFrom='below'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { difference } from 'lodash';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
@@ -148,9 +149,14 @@ class Status extends ImmutablePureComponent {
|
||||
isExpanded: undefined,
|
||||
threadExpanded: undefined,
|
||||
statusId: undefined,
|
||||
loadedStatusId: undefined,
|
||||
showMedia: undefined,
|
||||
loadedStatusId: undefined,
|
||||
revealBehindCW: undefined,
|
||||
/**
|
||||
* Holds the ids of newly added replies, excluding the initial load.
|
||||
* Used to highlight newly added replies in the UI
|
||||
*/
|
||||
newRepliesIds: [],
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
@@ -479,6 +485,7 @@ class Status extends ImmutablePureComponent {
|
||||
previousId={i > 0 ? list[i - 1] : undefined}
|
||||
nextId={list[i + 1] || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
shouldHighlightOnMount={this.state.newRepliesIds.includes(id)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -520,11 +527,20 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds } = this.props;
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
this._scrollStatusIntoView();
|
||||
}
|
||||
|
||||
// Only highlight replies after the initial load
|
||||
if (prevProps.descendantsIds.length) {
|
||||
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
|
||||
|
||||
if (newRepliesIds.length) {
|
||||
this.setState({newRepliesIds});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@@ -662,8 +678,8 @@ class Status extends ImmutablePureComponent {
|
||||
</div>
|
||||
</Hotkeys>
|
||||
|
||||
{remoteHint}
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ import { layoutFromWindow } from 'flavours/glitch/is_mobile';
|
||||
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { handleAnimateGif } from '../emoji/handlers';
|
||||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
|
||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
|
||||
|
||||
import BundleColumnError from './components/bundle_column_error';
|
||||
import { NavigationBar } from './components/navigation_bar';
|
||||
@@ -392,6 +393,11 @@ class UI extends PureComponent {
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
|
||||
if (!autoPlayGif) {
|
||||
window.addEventListener('mouseover', handleAnimateGif, { passive: true });
|
||||
window.addEventListener('mouseout', handleAnimateGif, { passive: true });
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', this.handleDragEnter, false);
|
||||
document.addEventListener('dragover', this.handleDragOver, false);
|
||||
document.addEventListener('drop', this.handleDrop, false);
|
||||
@@ -450,6 +456,8 @@ class UI extends PureComponent {
|
||||
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('mouseover', handleAnimateGif);
|
||||
window.removeEventListener('mouseout', handleAnimateGif);
|
||||
|
||||
document.removeEventListener('dragenter', this.handleDragEnter);
|
||||
document.removeEventListener('dragover', this.handleDragOver);
|
||||
|
||||
@@ -163,7 +163,7 @@ $content-width: 840px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $content-width + $sidebar-width) {
|
||||
@media screen and (max-width: ($content-width + $sidebar-width)) {
|
||||
.sidebar-wrapper--empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1086,6 +1086,17 @@ a.name-tag,
|
||||
}
|
||||
}
|
||||
|
||||
&__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:not(.no-wrap) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
padding: 0 15px;
|
||||
color: $dark-text-color;
|
||||
@@ -1102,10 +1113,8 @@ a.name-tag,
|
||||
}
|
||||
}
|
||||
|
||||
&__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
&__actions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
&__permissions {
|
||||
|
||||
@@ -1657,6 +1657,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-reduce-motion &--highlighted-entry::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgb(from $ui-highlight-color r g b / 20%);
|
||||
opacity: 0;
|
||||
animation: fade 0.7s reverse both 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status__relative-time {
|
||||
@@ -3025,7 +3035,6 @@ a.account__display-name {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
|
||||
&.unscrollable {
|
||||
@@ -3201,6 +3210,29 @@ a.account__display-name {
|
||||
}
|
||||
}
|
||||
|
||||
.column__alert {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding-inline: 10px;
|
||||
margin-top: 1rem;
|
||||
margin-inline: auto;
|
||||
|
||||
@media (max-width: #{$mobile-menu-breakpoint - 1}) {
|
||||
bottom: 4rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
// Make all nested alerts occupy the same space
|
||||
// rather than stack
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ui {
|
||||
--mobile-bottom-nav-height: 55px;
|
||||
--last-content-item-border-width: 2px;
|
||||
@@ -3241,7 +3273,6 @@ a.account__display-name {
|
||||
.column,
|
||||
.drawer {
|
||||
flex: 1 1 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (width > $mobile-breakpoint) {
|
||||
@@ -10697,6 +10728,21 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
.notification-bar__loading-indicator {
|
||||
--spinner-size: 22px;
|
||||
|
||||
position: relative;
|
||||
height: var(--spinner-size);
|
||||
width: var(--spinner-size);
|
||||
margin-inline-start: 2px;
|
||||
|
||||
svg {
|
||||
color: $white;
|
||||
height: var(--spinner-size);
|
||||
width: var(--spinner-size);
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-header {
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
padding: 15px;
|
||||
|
||||
@@ -12,11 +12,7 @@ export function isProduction() {
|
||||
else return import.meta.env.PROD;
|
||||
}
|
||||
|
||||
export type Features =
|
||||
| 'modern_emojis'
|
||||
| 'outgoing_quotes'
|
||||
| 'fasp'
|
||||
| 'http_message_signatures';
|
||||
export type Features = 'modern_emojis' | 'fasp' | 'http_message_signatures';
|
||||
|
||||
export function isFeatureEnabled(feature: Features) {
|
||||
return initialState?.features.includes(feature) ?? false;
|
||||
|
||||
@@ -8,6 +8,7 @@ const meta = {
|
||||
component: Alert,
|
||||
args: {
|
||||
isActive: true,
|
||||
isLoading: false,
|
||||
animateFrom: 'side',
|
||||
title: '',
|
||||
message: '',
|
||||
@@ -20,6 +21,12 @@ const meta = {
|
||||
type: 'boolean',
|
||||
description: 'Animate to the active (displayed) state of the alert',
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Display a loading indicator in the alert, replacing the dismiss button if present',
|
||||
},
|
||||
animateFrom: {
|
||||
control: 'radio',
|
||||
type: 'string',
|
||||
@@ -108,3 +115,11 @@ export const InSizedContainer: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLoadingIndicator: Story = {
|
||||
args: {
|
||||
...WithDismissButton.args,
|
||||
isLoading: true,
|
||||
},
|
||||
render: InSizedContainer.render,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
@@ -10,21 +11,23 @@ import { IconButton } from '../icon_button';
|
||||
* Snackbar/Toast-style notification component.
|
||||
*/
|
||||
export const Alert: React.FC<{
|
||||
isActive?: boolean;
|
||||
animateFrom?: 'side' | 'below';
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
onActionClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
isActive?: boolean;
|
||||
isLoading?: boolean;
|
||||
animateFrom?: 'side' | 'below';
|
||||
}> = ({
|
||||
isActive,
|
||||
animateFrom = 'side',
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
onActionClick,
|
||||
onDismiss,
|
||||
isActive,
|
||||
isLoading,
|
||||
animateFrom = 'side',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -51,7 +54,13 @@ export const Alert: React.FC<{
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDismiss && (
|
||||
{isLoading && (
|
||||
<span className='notification-bar__loading-indicator'>
|
||||
<LoadingIndicator />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onDismiss && !isLoading && (
|
||||
<IconButton
|
||||
title={intl.formatMessage({
|
||||
id: 'dismissable_banner.dismiss',
|
||||
|
||||
@@ -14,7 +14,10 @@ export const DisplayNameWithoutDomain: FC<
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, className, children, ...props }) => {
|
||||
return (
|
||||
<span {...props} className={classNames('display-name', className)}>
|
||||
<span
|
||||
{...props}
|
||||
className={classNames('display-name animate-parent', className)}
|
||||
>
|
||||
<bdi>
|
||||
{account ? (
|
||||
<EmojiHTML
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A helper component for managing the rendering of components that
|
||||
* need to stay in the DOM a bit longer to finish their CSS exit animation.
|
||||
*
|
||||
* In the future, replace this component with plain CSS once that is feasible.
|
||||
* This will require broader support for `transition-behavior: allow-discrete`
|
||||
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
|
||||
*/
|
||||
export const ExitAnimationWrapper: React.FC<{
|
||||
/**
|
||||
* Set this to true to indicate that the nested component should be rendered
|
||||
*/
|
||||
isActive: boolean;
|
||||
/**
|
||||
* How long the component should be rendered after `isActive` was set to `false`
|
||||
*/
|
||||
delayMs?: number;
|
||||
/**
|
||||
* Set this to true to also delay the entry of the nested component until after
|
||||
* another one has exited full.
|
||||
*/
|
||||
withEntryDelay?: boolean;
|
||||
/**
|
||||
* Render prop that provides the nested component with the `delayedIsActive` flag
|
||||
*/
|
||||
children: (delayedIsActive: boolean) => React.ReactNode;
|
||||
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
|
||||
const [delayedIsActive, setDelayedIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && !withEntryDelay) {
|
||||
setDelayedIsActive(true);
|
||||
|
||||
return () => '';
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
setDelayedIsActive(isActive);
|
||||
}, delayMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [isActive, delayMs, withEntryDelay]);
|
||||
|
||||
if (!isActive && !delayedIsActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children(isActive && delayedIsActive);
|
||||
};
|
||||
@@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent {
|
||||
unread: PropTypes.bool,
|
||||
showThread: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
shouldHighlightOnMount: PropTypes.bool,
|
||||
getScrollPosition: PropTypes.func,
|
||||
updateScrollBottom: PropTypes.func,
|
||||
cacheMediaWidth: PropTypes.func,
|
||||
@@ -567,6 +568,7 @@ class Status extends ImmutablePureComponent {
|
||||
'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted,
|
||||
'status--is-quote': isQuotedPost,
|
||||
'status--has-quote': !!status.get('quote'),
|
||||
'status--highlighted-entry': this.props.shouldHighlightOnMount,
|
||||
})
|
||||
}
|
||||
data-id={status.get('id')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { statusFactoryState } from '@/testing/factories';
|
||||
|
||||
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||
import { BoostButton } from './boost_button';
|
||||
|
||||
interface StoryProps {
|
||||
visibility: StatusVisibility;
|
||||
@@ -38,10 +38,7 @@ const meta = {
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<StatusBoostButton
|
||||
status={argsToStatus(args)}
|
||||
counters={args.reblogCount > 0}
|
||||
/>
|
||||
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
|
||||
),
|
||||
} satisfies Meta<StoryProps>;
|
||||
|
||||
@@ -78,12 +75,3 @@ export const Mine: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Legacy: Story = {
|
||||
render: (args) => (
|
||||
<LegacyReblogButton
|
||||
status={argsToStatus(args)}
|
||||
counters={args.reblogCount > 0}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Status } from '@/mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import type { SomeRequired } from '@/mastodon/utils/types';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
@@ -47,10 +46,7 @@ interface ReblogButtonProps {
|
||||
|
||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||
|
||||
export const StatusBoostButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const statusState = useAppSelector((state) =>
|
||||
@@ -192,65 +188,3 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Legacy helpers
|
||||
|
||||
// Switch between the legacy and new reblog button based on feature flag.
|
||||
export const BoostButton: FC<ReblogButtonProps> = (props) => {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
return <StatusBoostButton {...props} />;
|
||||
}
|
||||
return <LegacyReblogButton {...props} />;
|
||||
};
|
||||
|
||||
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() => boostItemState(statusState),
|
||||
[statusState],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (statusState.isLoggedIn) {
|
||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, statusState.isLoggedIn],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
active={!!status.get('reblogged')}
|
||||
title={intl.formatMessage(meta ?? title)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
counter={
|
||||
counters
|
||||
? (status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../../initial_state';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
import { isFeatureEnabled } from '../../utils/environment';
|
||||
import { BoostButton } from '../status/boost_button';
|
||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||
|
||||
@@ -281,7 +280,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
if (writtenByMe || withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
@@ -140,32 +140,6 @@ class StatusContent extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._updateStatusLinks();
|
||||
}
|
||||
@@ -257,7 +231,13 @@ class StatusContent extends PureComponent {
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div
|
||||
className={classNames}
|
||||
ref={this.setRef}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
key='status-content'
|
||||
>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
@@ -274,7 +254,7 @@ class StatusContent extends PureComponent {
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className={classNames} ref={this.setRef}>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
|
||||
@@ -47,8 +47,6 @@ import Status from '../components/status';
|
||||
import { deleteModal } from '../initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
@@ -81,9 +79,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||
},
|
||||
|
||||
onQuote (status) {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
}
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
|
||||
@@ -379,36 +379,6 @@ export const AccountHeader: React.FC<{
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-original') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-static') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
@@ -808,11 +778,9 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames('account__header', {
|
||||
className={classNames('account__header animate-parent', {
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
|
||||
@@ -12,14 +12,12 @@ import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
|
||||
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
|
||||
import { messages as privacyMessages } from './privacy_dropdown';
|
||||
|
||||
@@ -43,9 +41,6 @@ interface PrivacyDropdownProps {
|
||||
}
|
||||
|
||||
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
||||
if (!isFeatureEnabled('outgoing_quotes')) {
|
||||
return <PrivacyDropdownContainer {...props} />;
|
||||
}
|
||||
return <PrivacyModalButton {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
@@ -57,32 +56,6 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
||||
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
|
||||
const accounts = useSelector(state => getAccounts(state, accountIds));
|
||||
|
||||
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (unread) {
|
||||
dispatch(markConversationRead(id));
|
||||
@@ -163,7 +136,7 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||
</div>
|
||||
|
||||
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<div className='conversation__content__names animate-parent'>
|
||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
@@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const original = emoji.getAttribute('data-original');
|
||||
if (original) emoji.src = original;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const staticUrl = emoji.getAttribute('data-static');
|
||||
if (staticUrl) emoji.src = staticUrl;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) return;
|
||||
|
||||
@@ -185,9 +151,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
|
||||
{account.get('note').length > 0 && (
|
||||
<div
|
||||
className='account-card__bio translate'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className='account-card__bio translate animate-parent'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { useEmojify } from './hooks';
|
||||
@@ -7,12 +9,13 @@ import type { CustomEmojiMapArg } from './types';
|
||||
|
||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'dangerouslySetInnerHTML'
|
||||
'dangerouslySetInnerHTML' | 'className'
|
||||
> & {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
as?: Element;
|
||||
shallow?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ModernEmojiHTML = ({
|
||||
@@ -20,6 +23,7 @@ export const ModernEmojiHTML = ({
|
||||
htmlString,
|
||||
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||
shallow,
|
||||
className = '',
|
||||
...props
|
||||
}: EmojiHTMLProps<ElementType>) => {
|
||||
const emojifiedHtml = useEmojify({
|
||||
@@ -33,7 +37,11 @@ export const ModernEmojiHTML = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||
<Wrapper
|
||||
{...props}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +51,13 @@ export const EmojiHTML = <Element extends ElementType>(
|
||||
if (isModernEmojiEnabled()) {
|
||||
return <ModernEmojiHTML {...props} />;
|
||||
}
|
||||
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
|
||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
61
app/javascript/mastodon/features/emoji/handlers.ts
Normal file
61
app/javascript/mastodon/features/emoji/handlers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
|
||||
const PARENT_MAX_DEPTH = 10;
|
||||
|
||||
export function handleAnimateGif(event: MouseEvent) {
|
||||
// We already check this in ui/index.jsx, but just to be sure.
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, type } = event;
|
||||
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
setAnimateGif(target, animate);
|
||||
} else if (!(target instanceof HTMLElement) || target === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parent: HTMLElement | null = null;
|
||||
let iter = 0;
|
||||
|
||||
if (target.classList.contains('animate-parent')) {
|
||||
parent = target;
|
||||
} else {
|
||||
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
|
||||
let current: HTMLElement | null = target;
|
||||
while (current) {
|
||||
if (iter >= PARENT_MAX_DEPTH) {
|
||||
return; // We can just exit right now.
|
||||
}
|
||||
current = current.parentElement;
|
||||
if (current?.classList.contains('animate-parent')) {
|
||||
parent = current;
|
||||
break;
|
||||
}
|
||||
iter++;
|
||||
}
|
||||
}
|
||||
|
||||
// Affect all animated children within the parent.
|
||||
if (parent) {
|
||||
const animatedChildren =
|
||||
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
for (const child of animatedChildren) {
|
||||
setAnimateGif(child, animate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAnimateGif(image: HTMLImageElement, animate: boolean) {
|
||||
const { classList, dataset } = image;
|
||||
if (
|
||||
!classList.contains('custom-emoji') ||
|
||||
!dataset.static ||
|
||||
!dataset.original
|
||||
) {
|
||||
return;
|
||||
}
|
||||
image.src = animate ? dataset.original : dataset.static;
|
||||
}
|
||||
@@ -111,42 +111,14 @@ class ContentWithRouter extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { announcement } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='announcements__item__content translate'
|
||||
className='announcements__item__content translate animate-parent'
|
||||
ref={this.setRef}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||
<animated.button
|
||||
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||
onClick={this.handleClick}
|
||||
title={`:${shortCode}:`}
|
||||
style={this.props.style}
|
||||
// This does not use animate-parent as this component is directly rendered by React.
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
<span className='reactions-bar__item__emoji'>
|
||||
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
|
||||
</span>
|
||||
<span className='reactions-bar__item__count'>
|
||||
<AnimatedNumber value={reaction.get('count')} />
|
||||
</span>
|
||||
</animated.button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||
@@ -63,12 +62,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
<td><kbd>b</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||
</tr>
|
||||
{isFeatureEnabled('outgoing_quotes') && (
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
||||
|
||||
@@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
[clickCoordinatesRef, statusId, account, history],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-original');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-static');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentWarningClick = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
@@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='notification-group__embedded-status'
|
||||
className='notification-group__embedded-status animate-parent'
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className='notification-group__embedded-status__account'>
|
||||
<Avatar account={account} size={16} />
|
||||
|
||||
@@ -19,7 +19,6 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../../../initial_state';
|
||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import { BoostButton } from '@/mastodon/components/status/boost_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -237,7 +236,7 @@ class ActionBar extends PureComponent {
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
if (!['private', 'direct'].includes(status.get('visibility'))) {
|
||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||
}
|
||||
menu.push(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import {
|
||||
fetchContext,
|
||||
@@ -8,31 +8,80 @@ import {
|
||||
} from 'mastodon/actions/statuses';
|
||||
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||
import { Alert } from 'mastodon/components/alert';
|
||||
import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const AnimatedAlert: React.FC<
|
||||
React.ComponentPropsWithoutRef<typeof Alert> & { withEntryDelay?: boolean }
|
||||
> = ({ isActive = false, withEntryDelay, ...props }) => (
|
||||
<ExitAnimationWrapper withEntryDelay isActive={isActive}>
|
||||
{(delayedIsActive) => <Alert isActive={delayedIsActive} {...props} />}
|
||||
</ExitAnimationWrapper>
|
||||
);
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
moreFound: {
|
||||
id: 'status.context.more_replies_found',
|
||||
defaultMessage: 'More replies found',
|
||||
},
|
||||
show: {
|
||||
id: 'status.context.show',
|
||||
defaultMessage: 'Show',
|
||||
},
|
||||
loadingInitial: {
|
||||
id: 'status.context.loading',
|
||||
defaultMessage: 'Checking for more replies',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
loadingMore: {
|
||||
id: 'status.context.loading_more',
|
||||
defaultMessage: 'Loading more replies',
|
||||
},
|
||||
success: {
|
||||
id: 'status.context.loading_success',
|
||||
defaultMessage: 'All replies loaded',
|
||||
},
|
||||
error: {
|
||||
id: 'status.context.loading_error',
|
||||
defaultMessage: "Couldn't load new replies",
|
||||
},
|
||||
retry: {
|
||||
id: 'status.context.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
});
|
||||
|
||||
type LoadingState =
|
||||
| 'idle'
|
||||
| 'more-available'
|
||||
| 'loading-initial'
|
||||
| 'loading-more'
|
||||
| 'success'
|
||||
| 'error';
|
||||
|
||||
export const RefreshController: React.FC<{
|
||||
statusId: string;
|
||||
}> = ({ statusId }) => {
|
||||
const refresh = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
);
|
||||
const autoRefresh = useAppSelector(
|
||||
(state) =>
|
||||
!state.contexts.replies[statusId] ||
|
||||
state.contexts.replies[statusId].length === 0,
|
||||
const currentReplyCount = useAppSelector(
|
||||
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
||||
);
|
||||
const autoRefresh = !currentReplyCount;
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
||||
);
|
||||
|
||||
const [wasDismissed, setWasDismissed] = useState(false);
|
||||
const dismissPrompt = useCallback(() => {
|
||||
setWasDismissed(true);
|
||||
setLoadingState('idle');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
@@ -45,67 +94,104 @@ export const RefreshController: React.FC<{
|
||||
|
||||
if (result.async_refresh.result_count > 0) {
|
||||
if (autoRefresh) {
|
||||
void dispatch(fetchContext({ statusId }));
|
||||
return '';
|
||||
void dispatch(fetchContext({ statusId })).then(() => {
|
||||
setLoadingState('idle');
|
||||
});
|
||||
} else {
|
||||
setLoadingState('more-available');
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
} else {
|
||||
setLoadingState('idle');
|
||||
}
|
||||
} else {
|
||||
scheduleRefresh(refresh);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
if (refresh) {
|
||||
if (refresh && !wasDismissed) {
|
||||
scheduleRefresh(refresh);
|
||||
setLoadingState('loading-initial');
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [dispatch, setReady, statusId, refresh, autoRefresh]);
|
||||
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide success message after a short delay
|
||||
if (loadingState === 'success') {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setLoadingState('idle');
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
return () => '';
|
||||
}, [loadingState]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoading(true);
|
||||
setReady(false);
|
||||
setLoadingState('loading-more');
|
||||
|
||||
dispatch(fetchContext({ statusId }))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
setLoadingState('success');
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
setLoadingState('error');
|
||||
});
|
||||
}, [dispatch, setReady, statusId]);
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (ready && !loading) {
|
||||
if (loadingState === 'loading-initial') {
|
||||
return (
|
||||
<button className='load-more load-gap' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='status.context.load_new_replies'
|
||||
defaultMessage='New replies available'
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loadingInitial)}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!refresh && !loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<LoadingIndicator />
|
||||
<div className='column__alert' role='status' aria-live='polite'>
|
||||
<AnimatedAlert
|
||||
isActive={loadingState === 'more-available'}
|
||||
message={intl.formatMessage(messages.moreFound)}
|
||||
action={intl.formatMessage(messages.show)}
|
||||
onActionClick={handleClick}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
isLoading
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'loading-more'}
|
||||
message={intl.formatMessage(messages.loadingMore)}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'error'}
|
||||
message={intl.formatMessage(messages.error)}
|
||||
action={intl.formatMessage(messages.retry)}
|
||||
onActionClick={handleClick}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'success'}
|
||||
message={intl.formatMessage(messages.success)}
|
||||
animateFrom='below'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { difference } from 'lodash';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
@@ -150,6 +151,11 @@ class Status extends ImmutablePureComponent {
|
||||
fullscreen: false,
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
loadedStatusId: undefined,
|
||||
/**
|
||||
* Holds the ids of newly added replies, excluding the initial load.
|
||||
* Used to highlight newly added replies in the UI
|
||||
*/
|
||||
newRepliesIds: [],
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
@@ -462,6 +468,7 @@ class Status extends ImmutablePureComponent {
|
||||
previousId={i > 0 ? list[i - 1] : undefined}
|
||||
nextId={list[i + 1] || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
shouldHighlightOnMount={this.state.newRepliesIds.includes(id)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -495,11 +502,20 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds } = this.props;
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
this._scrollStatusIntoView();
|
||||
}
|
||||
|
||||
// Only highlight replies after the initial load
|
||||
if (prevProps.descendantsIds.length) {
|
||||
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
|
||||
|
||||
if (newRepliesIds.length) {
|
||||
this.setState({newRepliesIds});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@@ -632,8 +648,8 @@ class Status extends ImmutablePureComponent {
|
||||
</div>
|
||||
</Hotkeys>
|
||||
|
||||
{remoteHint}
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
|
||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { handleAnimateGif } from '../emoji/handlers';
|
||||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
|
||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
|
||||
|
||||
import BundleColumnError from './components/bundle_column_error';
|
||||
import { NavigationBar } from './components/navigation_bar';
|
||||
@@ -379,6 +380,11 @@ class UI extends PureComponent {
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
|
||||
if (!autoPlayGif) {
|
||||
window.addEventListener('mouseover', handleAnimateGif, { passive: true });
|
||||
window.addEventListener('mouseout', handleAnimateGif, { passive: true });
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', this.handleDragEnter, false);
|
||||
document.addEventListener('dragover', this.handleDragOver, false);
|
||||
document.addEventListener('drop', this.handleDrop, false);
|
||||
@@ -404,6 +410,8 @@ class UI extends PureComponent {
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('mouseover', handleAnimateGif);
|
||||
window.removeEventListener('mouseout', handleAnimateGif);
|
||||
|
||||
document.removeEventListener('dragenter', this.handleDragEnter);
|
||||
document.removeEventListener('dragover', this.handleDragOver);
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Zrušit boostnutí",
|
||||
"status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek",
|
||||
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
||||
"status.contains_quote": "Obsahuje citaci",
|
||||
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
|
||||
"status.context.loading": "Hledání dalších odpovědí",
|
||||
"status.continued_thread": "Pokračuje ve vlákně",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Příspěvek odstraněn autorem",
|
||||
"status.quote_followers_only": "Pouze moji sledující mohou citovat tento příspěvek",
|
||||
"status.quote_manual_review": "Autor provede manuální kontrolu",
|
||||
"status.quote_noun": "Citace",
|
||||
"status.quote_policy_change": "Změňte, kdo může citovat",
|
||||
"status.quote_post_author": "Citovali příspěvek od @{name}",
|
||||
"status.quote_private": "Soukromé příspěvky nelze citovat",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Dadhybu",
|
||||
"status.cannot_quote": "Does dim caniatâd i chi ddyfynnu'r postiad hwn",
|
||||
"status.cannot_reblog": "Does dim modd hybu'r postiad hwn",
|
||||
"status.contains_quote": "Yn cynnwys dyfyniad",
|
||||
"status.context.load_new_replies": "Mae atebion newydd ar gael",
|
||||
"status.context.loading": "Yn chwilio am fwy o atebion",
|
||||
"status.continued_thread": "Edefyn parhaus",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Postiad wedi'i ddileu gan yr awdur",
|
||||
"status.quote_followers_only": "Dim ond dilynwyr all ddyfynnu'r postiad hwn",
|
||||
"status.quote_manual_review": "Bydd yr awdur yn ei adolygu ei hyn",
|
||||
"status.quote_noun": "Dyfynnu",
|
||||
"status.quote_policy_change": "Newid pwy all ddyfynnu",
|
||||
"status.quote_post_author": "Wedi dyfynnu postiad gan @{name}",
|
||||
"status.quote_private": "Does dim modd dyfynnu postiadau preifat",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Fjern fremhævelse",
|
||||
"status.cannot_quote": "Du har ikke tilladelse til at citere dette indlæg",
|
||||
"status.cannot_reblog": "Dette indlæg kan ikke fremhæves",
|
||||
"status.contains_quote": "Indeholder citat",
|
||||
"status.context.load_new_replies": "Nye svar tilgængelige",
|
||||
"status.context.loading": "Tjekker for flere svar",
|
||||
"status.continued_thread": "Fortsat tråd",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Indlæg fjernet af forfatter",
|
||||
"status.quote_followers_only": "Kun følgere kan citere dette indlæg",
|
||||
"status.quote_manual_review": "Forfatter vil manuelt gennemgå",
|
||||
"status.quote_noun": "Citat",
|
||||
"status.quote_policy_change": "Ændr hvem der kan citere",
|
||||
"status.quote_post_author": "Citerede et indlæg fra @{name}",
|
||||
"status.quote_private": "Private indlæg kan ikke citeres",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Beitrag nicht mehr teilen",
|
||||
"status.cannot_quote": "Dir ist es nicht gestattet, diesen Beitrag zu zitieren",
|
||||
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
|
||||
"status.contains_quote": "Enthält Zitat",
|
||||
"status.context.load_new_replies": "Neue Antworten verfügbar",
|
||||
"status.context.loading": "Weitere Antworten werden abgerufen",
|
||||
"status.continued_thread": "Fortgeführter Thread",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Beitrag durch Autor*in entfernt",
|
||||
"status.quote_followers_only": "Nur Follower können diesen Beitrag zitieren",
|
||||
"status.quote_manual_review": "Zitierte*r überprüft manuell",
|
||||
"status.quote_noun": "Zitat",
|
||||
"status.quote_policy_change": "Ändern, wer zitieren darf",
|
||||
"status.quote_post_author": "Zitierte einen Beitrag von @{name}",
|
||||
"status.quote_private": "Private Beiträge können nicht zitiert werden",
|
||||
|
||||
@@ -736,7 +736,7 @@
|
||||
"privacy.private.long": "Μόνο οι ακόλουθοί σας",
|
||||
"privacy.private.short": "Ακόλουθοι",
|
||||
"privacy.public.long": "Όλοι εντός και εκτός του Mastodon",
|
||||
"privacy.public.short": "Δημόσιο",
|
||||
"privacy.public.short": "Δημόσια",
|
||||
"privacy.quote.anyone": "{visibility}, ο καθένας μπορεί να παραθέσει",
|
||||
"privacy.quote.disabled": "{visibility}, παραθέσεις απενεργοποιημένες",
|
||||
"privacy.quote.limited": "{visibility}, παραθέσεις περιορισμένες",
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Ακύρωση ενίσχυσης",
|
||||
"status.cannot_quote": "Δε σας επιτρέπετε να παραθέσετε αυτή την ανάρτηση",
|
||||
"status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί",
|
||||
"status.contains_quote": "Περιέχει παράθεση",
|
||||
"status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες",
|
||||
"status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις",
|
||||
"status.continued_thread": "Συνεχιζόμενο νήματος",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Η ανάρτηση αφαιρέθηκε από τον συντάκτη",
|
||||
"status.quote_followers_only": "Μόνο οι ακόλουθοι μπορούν να παραθέσουν αυτή την ανάρτηση",
|
||||
"status.quote_manual_review": "Ο συντάκτης θα επανεξετάσει χειροκίνητα",
|
||||
"status.quote_noun": "Παράθεση",
|
||||
"status.quote_policy_change": "Αλλάξτε ποιός μπορεί να κάνει παράθεση",
|
||||
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
|
||||
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
|
||||
|
||||
@@ -865,8 +865,13 @@
|
||||
"status.cannot_quote": "You are not allowed to quote this post",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.contains_quote": "Contains quote",
|
||||
"status.context.load_new_replies": "New replies available",
|
||||
"status.context.loading": "Checking for more replies",
|
||||
"status.context.loading": "Loading more replies",
|
||||
"status.context.loading_error": "Couldn't load new replies",
|
||||
"status.context.loading_more": "Loading more replies",
|
||||
"status.context.loading_success": "All replies loaded",
|
||||
"status.context.more_replies_found": "More replies found",
|
||||
"status.context.retry": "Retry",
|
||||
"status.context.show": "Show",
|
||||
"status.continued_thread": "Continued thread",
|
||||
"status.copy": "Copy link to post",
|
||||
"status.delete": "Delete",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Quitar adhesión",
|
||||
"status.cannot_quote": "No te es permitido citar este mensaje",
|
||||
"status.cannot_reblog": "No se puede adherir a este mensaje",
|
||||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.load_new_replies": "Hay nuevas respuestas",
|
||||
"status.context.loading": "Buscando más respuestas",
|
||||
"status.continued_thread": "Continuación de hilo",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Mensaje eliminado por el autor",
|
||||
"status.quote_followers_only": "Solo los seguidores pueden citar este mensaje",
|
||||
"status.quote_manual_review": "El autor revisará manualmente",
|
||||
"status.quote_noun": "Cita",
|
||||
"status.quote_policy_change": "Cambiá quién puede citar",
|
||||
"status.quote_post_author": "Se citó un mensaje de @{name}",
|
||||
"status.quote_private": "No se pueden citar los mensajes privados",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Deshacer impulso",
|
||||
"status.cannot_quote": "No está permitido citar esta publicación",
|
||||
"status.cannot_reblog": "Esta publicación no puede ser impulsada",
|
||||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.load_new_replies": "Nuevas respuestas disponibles",
|
||||
"status.context.loading": "Comprobando si hay más respuestas",
|
||||
"status.continued_thread": "Hilo continuado",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Publicación eliminada por el autor",
|
||||
"status.quote_followers_only": "Solo los seguidores pueden citar esta publicación",
|
||||
"status.quote_manual_review": "El autor la revisará manualmente",
|
||||
"status.quote_noun": "Cita",
|
||||
"status.quote_policy_change": "Cambia quién puede citarte",
|
||||
"status.quote_post_author": "Ha citado una publicación de @{name}",
|
||||
"status.quote_private": "Las publicaciones privadas no pueden citarse",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Deshacer impulso",
|
||||
"status.cannot_quote": "No tienes permiso para citar esta publicación",
|
||||
"status.cannot_reblog": "Esta publicación no se puede impulsar",
|
||||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.load_new_replies": "Hay nuevas respuestas",
|
||||
"status.context.loading": "Buscando más respuestas",
|
||||
"status.continued_thread": "Continuó el hilo",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Publicación eliminada por el autor",
|
||||
"status.quote_followers_only": "Solo los seguidores pueden citar esta publicación",
|
||||
"status.quote_manual_review": "El autor revisará manualmente",
|
||||
"status.quote_noun": "Cita",
|
||||
"status.quote_policy_change": "Cambia quién puede citarte",
|
||||
"status.quote_post_author": "Ha citado una publicación de @{name}",
|
||||
"status.quote_private": "Las publicaciones privadas no pueden ser citadas",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Lõpeta jagamine",
|
||||
"status.cannot_quote": "Sul pole õigust seda postitust tsiteerida",
|
||||
"status.cannot_reblog": "Seda postitust ei saa jagada",
|
||||
"status.contains_quote": "Sisaldab tsitaati",
|
||||
"status.context.load_new_replies": "Leidub uusi vastuseid",
|
||||
"status.context.loading": "Kontrollin täiendavate vastuste olemasolu",
|
||||
"status.continued_thread": "Jätkatud lõim",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Autor on postituse eemaldanud",
|
||||
"status.quote_followers_only": "Vaid jälgijad saavad seda postitust tsiteerida",
|
||||
"status.quote_manual_review": "Autor vaatab selle üle",
|
||||
"status.quote_noun": "Tsitaat",
|
||||
"status.quote_policy_change": "Muuda neid, kes võivad tsiteerida",
|
||||
"status.quote_post_author": "Tsiteeris kasutaja @{name} postitust",
|
||||
"status.quote_private": "Otsepostituste tsiteerimine pole võimalik",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Peru tehostus",
|
||||
"status.cannot_quote": "Sinulla ei ole oikeutta lainata tätä julkaisua",
|
||||
"status.cannot_reblog": "Tätä julkaisua ei voi tehostaa",
|
||||
"status.contains_quote": "Sisältää lainauksen",
|
||||
"status.context.load_new_replies": "Uusia vastauksia saatavilla",
|
||||
"status.context.loading": "Tarkistetaan lisävastauksia",
|
||||
"status.continued_thread": "Jatkoi ketjua",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Tekijä on poistanut julkaisun",
|
||||
"status.quote_followers_only": "Vain seuraajat voivat lainata tätä julkaisua",
|
||||
"status.quote_manual_review": "Tekijä arvioi pyynnön manuaalisesti",
|
||||
"status.quote_noun": "Lainaus",
|
||||
"status.quote_policy_change": "Vaihda, kuka voi lainata",
|
||||
"status.quote_post_author": "Lainaa käyttäjän @{name} julkaisua",
|
||||
"status.quote_private": "Yksityisiä julkaisuja ei voi lainata",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Strika stimbran",
|
||||
"status.cannot_quote": "Tú hevur ikki loyvi at sitera hendan postin",
|
||||
"status.cannot_reblog": "Tað ber ikki til at stimbra hendan postin",
|
||||
"status.contains_quote": "Inniheldur sitat",
|
||||
"status.context.load_new_replies": "Nýggj svar tøk",
|
||||
"status.context.loading": "Kanni um tað eru fleiri svar",
|
||||
"status.continued_thread": "Framhaldandi tráður",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Posturin burturbeindur av høvundinum",
|
||||
"status.quote_followers_only": "Bara fylgjarar kunnu sitera hendan postin",
|
||||
"status.quote_manual_review": "Høvundurin fer at eftirkanna manuelt",
|
||||
"status.quote_noun": "Sitat",
|
||||
"status.quote_policy_change": "Broyt hvør kann sitera",
|
||||
"status.quote_post_author": "Siteraði ein post hjá @{name}",
|
||||
"status.quote_private": "Privatir postar kunnu ikki siterast",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Dímhol",
|
||||
"status.cannot_quote": "Ní cheadaítear duit an post seo a lua",
|
||||
"status.cannot_reblog": "Ní féidir an phostáil seo a mholadh",
|
||||
"status.contains_quote": "Tá luachan ann",
|
||||
"status.context.load_new_replies": "Freagraí nua ar fáil",
|
||||
"status.context.loading": "Ag seiceáil le haghaidh tuilleadh freagraí",
|
||||
"status.continued_thread": "Snáithe ar lean",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Post bainte ag an údar",
|
||||
"status.quote_followers_only": "Ní féidir ach le leantóirí an post seo a lua",
|
||||
"status.quote_manual_review": "Déanfaidh an t-údar athbhreithniú de láimh",
|
||||
"status.quote_noun": "Luachan",
|
||||
"status.quote_policy_change": "Athraigh cé a fhéadann luachan a thabhairt",
|
||||
"status.quote_post_author": "Luaigh mé post le @{name}",
|
||||
"status.quote_private": "Ní féidir poist phríobháideacha a lua",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Desfacer compartido",
|
||||
"status.cannot_quote": "Non tes permiso para citar esta publicación",
|
||||
"status.cannot_reblog": "Esta publicación non pode ser promovida",
|
||||
"status.contains_quote": "Contén unha cita",
|
||||
"status.context.load_new_replies": "Non hai respostas dispoñibles",
|
||||
"status.context.loading": "Mirando se hai máis respostas",
|
||||
"status.continued_thread": "Continua co fío",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Publicación retirada pola autora",
|
||||
"status.quote_followers_only": "Só as seguidoras poden citar esta publicación",
|
||||
"status.quote_manual_review": "A autora revisará manualmente",
|
||||
"status.quote_noun": "Cita",
|
||||
"status.quote_policy_change": "Cambia quen pode citarte",
|
||||
"status.quote_post_author": "Citou unha publicación de @{name}",
|
||||
"status.quote_private": "As publicacións privadas non se poden citar",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "הסרת הדהוד",
|
||||
"status.cannot_quote": "אין לך הרשאה לצטט את ההודעה הזו",
|
||||
"status.cannot_reblog": "לא ניתן להדהד חצרוץ זה",
|
||||
"status.contains_quote": "הודעה מכילה ציטוט",
|
||||
"status.context.load_new_replies": "הגיעו תגובות חדשות",
|
||||
"status.context.loading": "מחפש תגובות חדשות",
|
||||
"status.continued_thread": "שרשור מתמשך",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "ההודעה הוסרה על ידי המחבר.ת",
|
||||
"status.quote_followers_only": "רק עוקביך יוכלו לצטט את ההודעה",
|
||||
"status.quote_manual_review": "מחבר.ת ההודעה יחזרו אליך אחרי בדיקה",
|
||||
"status.quote_noun": "ציטוט",
|
||||
"status.quote_policy_change": "הגדרת הרשאה לציטוט הודעותיך",
|
||||
"status.quote_post_author": "ההודעה צוטטה על ידי @{name}",
|
||||
"status.quote_private": "הודעות פרטיות לא ניתנות לציטוט",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Megtolás visszavonása",
|
||||
"status.cannot_quote": "Nem idézheted ezt a bejegyzést",
|
||||
"status.cannot_reblog": "Ezt a bejegyzést nem lehet megtolni",
|
||||
"status.contains_quote": "Idézést tartalmaz",
|
||||
"status.context.load_new_replies": "Új válaszok érhetőek el",
|
||||
"status.context.loading": "További válaszok keresése",
|
||||
"status.continued_thread": "Folytatott szál",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "A szerző eltávolítta a bejegyzést",
|
||||
"status.quote_followers_only": "Csak a követők idézhetik ezt a bejegyzést",
|
||||
"status.quote_manual_review": "A szerző kézileg fogja jóváhagyni",
|
||||
"status.quote_noun": "Idézés",
|
||||
"status.quote_policy_change": "Módosítás, hogy kik idézhetnek",
|
||||
"status.quote_post_author": "Idézte @{name} bejegyzését",
|
||||
"status.quote_private": "A privát bejegyzések nem idézhetőek",
|
||||
|
||||
@@ -642,21 +642,21 @@
|
||||
"notifications.column_settings.alert": "デスクトップ通知",
|
||||
"notifications.column_settings.favourite": "お気に入り",
|
||||
"notifications.column_settings.filter_bar.advanced": "すべてのカテゴリを表示",
|
||||
"notifications.column_settings.filter_bar.category": "クイックフィルターバー:",
|
||||
"notifications.column_settings.filter_bar.category": "クイックフィルターバー",
|
||||
"notifications.column_settings.follow": "新しいフォロワー",
|
||||
"notifications.column_settings.follow_request": "新しいフォローリクエスト:",
|
||||
"notifications.column_settings.follow_request": "新しいフォローリクエスト",
|
||||
"notifications.column_settings.group": "グループ",
|
||||
"notifications.column_settings.mention": "返信",
|
||||
"notifications.column_settings.poll": "アンケート結果",
|
||||
"notifications.column_settings.push": "プッシュ通知",
|
||||
"notifications.column_settings.quote": "引用",
|
||||
"notifications.column_settings.reblog": "ブースト:",
|
||||
"notifications.column_settings.reblog": "ブースト",
|
||||
"notifications.column_settings.show": "カラムに表示",
|
||||
"notifications.column_settings.sound": "通知音を再生",
|
||||
"notifications.column_settings.status": "新しい投稿",
|
||||
"notifications.column_settings.unread_notifications.category": "未読の通知:",
|
||||
"notifications.column_settings.unread_notifications.category": "未読の通知",
|
||||
"notifications.column_settings.unread_notifications.highlight": "未読の通知を強調表示",
|
||||
"notifications.column_settings.update": "編集:",
|
||||
"notifications.column_settings.update": "編集",
|
||||
"notifications.filter.all": "すべて",
|
||||
"notifications.filter.boosts": "ブースト",
|
||||
"notifications.filter.favourites": "お気に入り",
|
||||
@@ -875,6 +875,7 @@
|
||||
"status.quote.cancel": "引用をキャンセル",
|
||||
"status.quote_error.filtered": "あなたのフィルター設定によって非表示になっています",
|
||||
"status.quote_error.pending_approval": "承認待ちの投稿",
|
||||
"status.quote_noun": "引用",
|
||||
"status.quotes": "{count, plural, other {引用}}",
|
||||
"status.read_more": "もっと見る",
|
||||
"status.reblog": "ブースト",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Niet langer boosten",
|
||||
"status.cannot_quote": "Je bent niet gemachtigd om dit bericht te citeren",
|
||||
"status.cannot_reblog": "Dit bericht kan niet geboost worden",
|
||||
"status.contains_quote": "Bevat citaat",
|
||||
"status.context.load_new_replies": "Nieuwe reacties beschikbaar",
|
||||
"status.context.loading": "Op nieuwe reacties aan het controleren",
|
||||
"status.continued_thread": "Vervolg van gesprek",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Bericht verwijderd door auteur",
|
||||
"status.quote_followers_only": "Alleen volgers mogen dit bericht citeren",
|
||||
"status.quote_manual_review": "De auteur gaat het handmatig beoordelen",
|
||||
"status.quote_noun": "Citaat",
|
||||
"status.quote_policy_change": "Wijzig wie jou mag citeren",
|
||||
"status.quote_post_author": "Citeerde een bericht van @{name}",
|
||||
"status.quote_private": "Citeren van berichten aan alleen volgers is niet mogelijk",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Yeniden paylaşımı geri al",
|
||||
"status.cannot_quote": "Bu gönderiyi alıntılamaya izniniz yok",
|
||||
"status.cannot_reblog": "Bu gönderi yeniden paylaşılamaz",
|
||||
"status.contains_quote": "Alıntı içeriyor",
|
||||
"status.context.load_new_replies": "Yeni yanıtlar mevcut",
|
||||
"status.context.loading": "Daha fazla yanıt için kontrol ediliyor",
|
||||
"status.continued_thread": "Devam eden akış",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Gönderi yazarı tarafından kaldırıldı",
|
||||
"status.quote_followers_only": "Sadece takipçiler bu gönderiyi alıntılayabilir",
|
||||
"status.quote_manual_review": "Yazar manuel olarak gözden geçirecek",
|
||||
"status.quote_noun": "Alıntı",
|
||||
"status.quote_policy_change": "Kimin alıntı yapabileceğini değiştirin",
|
||||
"status.quote_post_author": "@{name} adlı kullanıcının bir gönderisini alıntıladı",
|
||||
"status.quote_private": "Özel gönderiler alıntılanamaz",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "Bỏ đăng lại",
|
||||
"status.cannot_quote": "Bạn không được phép trích dẫn tút này",
|
||||
"status.cannot_reblog": "Không thể đăng lại tút này",
|
||||
"status.contains_quote": "Chứa trích dẫn",
|
||||
"status.context.load_new_replies": "Có những trả lời mới",
|
||||
"status.context.loading": "Kiểm tra nhiều trả lời hơn",
|
||||
"status.continued_thread": "Tiếp tục chủ đề",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "Tút gốc đã bị tác giả gỡ",
|
||||
"status.quote_followers_only": "Chỉ người theo dõi tôi có thể trích dẫn tút này",
|
||||
"status.quote_manual_review": "Người đăng sẽ duyệt thủ công",
|
||||
"status.quote_noun": "Trích dẫn",
|
||||
"status.quote_policy_change": "Thay đổi người có thể trích dẫn",
|
||||
"status.quote_post_author": "Trích dẫn từ tút của @{name}",
|
||||
"status.quote_private": "Không thể trích dẫn nhắn riêng",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "取消转嘟",
|
||||
"status.cannot_quote": "你无法引用此嘟文",
|
||||
"status.cannot_reblog": "不能转嘟这条嘟文",
|
||||
"status.contains_quote": "包含引用",
|
||||
"status.context.load_new_replies": "有新回复",
|
||||
"status.context.loading": "正在检查更多回复",
|
||||
"status.continued_thread": "上接嘟文串",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "嘟文已被作者删除",
|
||||
"status.quote_followers_only": "只有关注者才能引用这篇嘟文",
|
||||
"status.quote_manual_review": "嘟文作者将人工审核",
|
||||
"status.quote_noun": "引用",
|
||||
"status.quote_policy_change": "更改谁可以引用",
|
||||
"status.quote_post_author": "引用了 @{name} 的嘟文",
|
||||
"status.quote_private": "不能引用私人嘟文",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"status.cancel_reblog_private": "取消轉嘟",
|
||||
"status.cannot_quote": "您不被允許引用此嘟文",
|
||||
"status.cannot_reblog": "這則嘟文無法被轉嘟",
|
||||
"status.contains_quote": "包含引用嘟文",
|
||||
"status.context.load_new_replies": "有新回嘟",
|
||||
"status.context.loading": "正在檢查更多回嘟",
|
||||
"status.continued_thread": "接續討論串",
|
||||
@@ -903,6 +904,7 @@
|
||||
"status.quote_error.revoked": "嘟文已被作者刪除",
|
||||
"status.quote_followers_only": "只有我的跟隨者能引用此嘟文",
|
||||
"status.quote_manual_review": "嘟文作者將人工審閱",
|
||||
"status.quote_noun": "引用嘟文",
|
||||
"status.quote_policy_change": "變更可以引用的人",
|
||||
"status.quote_post_author": "已引用 @{name} 之嘟文",
|
||||
"status.quote_private": "無法引用私人嘟文",
|
||||
|
||||
@@ -12,11 +12,7 @@ export function isProduction() {
|
||||
else return import.meta.env.PROD;
|
||||
}
|
||||
|
||||
export type Features =
|
||||
| 'modern_emojis'
|
||||
| 'outgoing_quotes'
|
||||
| 'fasp'
|
||||
| 'http_message_signatures';
|
||||
export type Features = 'modern_emojis' | 'fasp' | 'http_message_signatures';
|
||||
|
||||
export function isFeatureEnabled(feature: Features) {
|
||||
return initialState?.features.includes(feature) ?? false;
|
||||
|
||||
@@ -163,7 +163,7 @@ $content-width: 840px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $content-width + $sidebar-width) {
|
||||
@media screen and (max-width: ($content-width + $sidebar-width)) {
|
||||
.sidebar-wrapper--empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1081,6 +1081,17 @@ a.name-tag,
|
||||
}
|
||||
}
|
||||
|
||||
&__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:not(.no-wrap) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
padding: 0 15px;
|
||||
color: $dark-text-color;
|
||||
@@ -1097,10 +1108,8 @@ a.name-tag,
|
||||
}
|
||||
}
|
||||
|
||||
&__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
&__actions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
&__permissions {
|
||||
|
||||
@@ -1597,6 +1597,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-reduce-motion &--highlighted-entry::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgb(from $ui-highlight-color r g b / 20%);
|
||||
opacity: 0;
|
||||
animation: fade 0.7s reverse both 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status__relative-time {
|
||||
@@ -2960,7 +2970,6 @@ a.account__display-name {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
|
||||
&.unscrollable {
|
||||
@@ -3136,6 +3145,29 @@ a.account__display-name {
|
||||
}
|
||||
}
|
||||
|
||||
.column__alert {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding-inline: 10px;
|
||||
margin-top: 1rem;
|
||||
margin-inline: auto;
|
||||
|
||||
@media (max-width: #{$mobile-menu-breakpoint - 1}) {
|
||||
bottom: 4rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
// Make all nested alerts occupy the same space
|
||||
// rather than stack
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ui {
|
||||
--mobile-bottom-nav-height: 55px;
|
||||
--last-content-item-border-width: 2px;
|
||||
@@ -3176,7 +3208,6 @@ a.account__display-name {
|
||||
.column,
|
||||
.drawer {
|
||||
flex: 1 1 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (width > $mobile-breakpoint) {
|
||||
@@ -10388,6 +10419,21 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
.notification-bar__loading-indicator {
|
||||
--spinner-size: 22px;
|
||||
|
||||
position: relative;
|
||||
height: var(--spinner-size);
|
||||
width: var(--spinner-size);
|
||||
margin-inline-start: 2px;
|
||||
|
||||
svg {
|
||||
color: $white;
|
||||
height: var(--spinner-size);
|
||||
width: var(--spinner-size);
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-header {
|
||||
border-bottom: 1px solid var(--background-border-color);
|
||||
padding: 15px;
|
||||
|
||||
@@ -9,7 +9,7 @@ class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
|
||||
quoted_status = status_from_uri(object_uri)
|
||||
return if quoted_status.nil? || !quoted_status.account.local? || !quoted_status.distributable?
|
||||
|
||||
if Mastodon::Feature.outgoing_quotes_enabled? && StatusPolicy.new(@account, quoted_status).quote?
|
||||
if StatusPolicy.new(@account, quoted_status).quote?
|
||||
accept_quote_request!(quoted_status)
|
||||
else
|
||||
reject_quote_request!(quoted_status)
|
||||
|
||||
@@ -38,7 +38,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
attribute :quote, key: :quote_uri, if: :quote?
|
||||
attribute :quote_authorization, if: :quote_authorization?
|
||||
|
||||
attribute :interaction_policy, if: -> { Mastodon::Feature.outgoing_quotes_enabled? }
|
||||
attribute :interaction_policy
|
||||
|
||||
def id
|
||||
raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only]
|
||||
|
||||
@@ -6,5 +6,5 @@ class REST::ShallowStatusSerializer < REST::StatusSerializer
|
||||
# It looks like redefining one `has_one` requires redefining all inherited ones
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? }
|
||||
has_one :quote_approval
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? }
|
||||
has_one :quote_approval
|
||||
|
||||
delegate :local?, to: :object
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class PostStatusService < BaseService
|
||||
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
|
||||
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
|
||||
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
|
||||
@visibility = :private if @quoted_status&.private_visibility?
|
||||
@visibility = :private if @quoted_status&.private_visibility? && %i(public unlisted).include?(@visibility&.to_sym)
|
||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||
@scheduled_at = nil if scheduled_in_the_past?
|
||||
rescue ArgumentError
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- else
|
||||
= l(announcement.created_at)
|
||||
|
||||
%div
|
||||
.announcements-list__item__actions
|
||||
- if can?(:distribute, announcement)
|
||||
= table_link_to 'mail', t('admin.terms_of_service.notify_users'), admin_announcement_preview_path(announcement)
|
||||
- if can?(:update, announcement)
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
= link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
|
||||
·
|
||||
%abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
|
||||
%div
|
||||
.announcements-list__item__actions
|
||||
= table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#{rule_counter + 1}.
|
||||
= truncate(rule.text)
|
||||
|
||||
.announcements-list__item__action-bar
|
||||
.announcements-list__item__action-bar.no-wrap
|
||||
.announcements-list__item__meta
|
||||
= rule.hint
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
.announcements-list__item__meta
|
||||
= truncate(warning_preset.text)
|
||||
|
||||
%div
|
||||
.announcements-list__item__actions
|
||||
= table_link_to 'delete', t('admin.warning_presets.delete'), admin_warning_preset_path(warning_preset), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, warning_preset)
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
|
||||
%abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)
|
||||
|
||||
%div
|
||||
.announcements-list__item__actions
|
||||
= table_link_to 'edit', t('admin.webhooks.edit'), edit_admin_webhook_path(webhook) if can?(:update, webhook)
|
||||
= table_link_to 'delete', t('admin.webhooks.delete'), admin_webhook_path(webhook), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, webhook)
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
.permissions-list__item__text__type
|
||||
= t('filters.index.statuses_long', count: filter.statuses.size)
|
||||
|
||||
.announcements-list__item__action-bar
|
||||
.announcements-list__item__meta
|
||||
.filters-list__item__action-bar
|
||||
.filters-list__item__meta
|
||||
= t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', '))
|
||||
|
||||
%div
|
||||
.filters-list__item__actions
|
||||
= table_link_to 'edit', t('filters.edit.title'), edit_filter_path(filter)
|
||||
= table_link_to 'close', t('filters.index.delete'), filter_path(filter), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
= t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))
|
||||
|
||||
- unless application.superapp? || current_account.unavailable?
|
||||
%div
|
||||
.announcements-list__item__actions
|
||||
= table_link_to 'close', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
|
||||
|
||||
.announcements-list__item__permissions
|
||||
|
||||
@@ -114,7 +114,7 @@ el:
|
||||
other: Αυτός ο λογαριασμός έχει <strong>%{count}</strong> παραπτώματα.
|
||||
promote: Προαγωγή
|
||||
protocol: Πρωτόκολλο
|
||||
public: Δημόσιο
|
||||
public: Δημόσιος
|
||||
push_subscription_expires: Η εγγραφή PuSH λήγει
|
||||
redownload: Ανανέωση άβαταρ
|
||||
redownloaded_msg: Επιτυχής ανανέωση προφίλ του/της %{username} από την πηγή
|
||||
@@ -1919,7 +1919,7 @@ el:
|
||||
visibilities:
|
||||
direct: Ιδιωτική επισήμανση
|
||||
private: Μόνο ακόλουθοι
|
||||
public: Δημόσιο
|
||||
public: Δημόσια
|
||||
public_long: Όλοι εντός και εκτός του Mastodon
|
||||
unlisted: Ήσυχα δημόσια
|
||||
unlisted_long: Κρυμμένη από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές
|
||||
|
||||
@@ -572,6 +572,7 @@ ja:
|
||||
title: モデレーション
|
||||
moderation_notes:
|
||||
create: モデレーションノートを追加
|
||||
title: モデレーションメモ
|
||||
private_comment: コメント (非公開)
|
||||
public_comment: コメント (公開)
|
||||
purge: パージ
|
||||
@@ -1067,14 +1068,18 @@ ja:
|
||||
trending: トレンド
|
||||
username_blocks:
|
||||
add_new: ルールを作成
|
||||
block_registrations: 登録拒否
|
||||
comparison:
|
||||
contains: 含む
|
||||
equals: 一致
|
||||
contains_html: "%{string}を含む"
|
||||
delete: 削除
|
||||
edit:
|
||||
title: ユーザー名ルールの編集
|
||||
matches_exactly_html: "%{string}に一致"
|
||||
new:
|
||||
create: ルールを作成
|
||||
title: ユーザー名ルール
|
||||
warning_presets:
|
||||
add_new: 追加
|
||||
delete: 削除
|
||||
@@ -1679,6 +1684,7 @@ ja:
|
||||
self_vote: 自分のアンケートには解答できません
|
||||
too_few_options: は複数必要です
|
||||
too_many_options: は%{max}個までです
|
||||
vote: 投票
|
||||
preferences:
|
||||
other: その他
|
||||
posting_defaults: デフォルトの投稿設定
|
||||
|
||||
@@ -27,7 +27,7 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true'
|
||||
end
|
||||
end
|
||||
|
||||
on_worker_boot do
|
||||
before_worker_boot do
|
||||
# Ruby process metrics (memory, GC, etc)
|
||||
PrometheusExporter::Instrumentation::Process.start(type: 'puma')
|
||||
|
||||
@@ -44,7 +44,7 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true'
|
||||
end
|
||||
end
|
||||
|
||||
on_worker_boot do
|
||||
before_worker_boot do
|
||||
ActiveSupport.on_load(:active_record) do
|
||||
ActiveRecord::Base.establish_connection
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ module Mastodon
|
||||
|
||||
def api_versions
|
||||
{
|
||||
mastodon: Mastodon::Feature.outgoing_quotes_enabled? ? 7 : 6,
|
||||
mastodon: 7,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ActivityPub::Activity::QuoteRequest, feature: :outgoing_quotes do
|
||||
RSpec.describe ActivityPub::Activity::QuoteRequest do
|
||||
let(:sender) { Fabricate(:account, domain: 'example.com') }
|
||||
let(:recipient) { Fabricate(:account) }
|
||||
let(:quoted_post) { Fabricate(:status, account: recipient) }
|
||||
|
||||
@@ -28,7 +28,7 @@ RSpec.describe StatusCacheHydrator do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling a status with a quote policy', feature: :outgoing_quotes do
|
||||
context 'when handling a status with a quote policy' do
|
||||
let(:status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) }
|
||||
|
||||
before do
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Interaction policies', feature: :outgoing_quotes do
|
||||
RSpec.describe 'Interaction policies' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:scopes) { 'write:statuses' }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||
|
||||
@@ -158,7 +158,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'without a quote policy', feature: :outgoing_quotes do
|
||||
context 'without a quote policy' do
|
||||
let(:user) do
|
||||
Fabricate(:user, settings: { default_quote_policy: 'followers' })
|
||||
end
|
||||
@@ -180,7 +180,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'without a quote policy and the user defaults to nobody', feature: :outgoing_quotes do
|
||||
context 'without a quote policy and the user defaults to nobody' do
|
||||
let(:user) do
|
||||
Fabricate(:user, settings: { default_quote_policy: 'nobody' })
|
||||
end
|
||||
@@ -202,7 +202,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a quote policy', feature: :outgoing_quotes do
|
||||
context 'with a quote policy' do
|
||||
let(:quoted_status) { Fabricate(:status, account: user.account) }
|
||||
let(:params) do
|
||||
{
|
||||
@@ -227,7 +227,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a self-quote post', feature: :outgoing_quotes do
|
||||
context 'with a self-quote post' do
|
||||
let(:quoted_status) { Fabricate(:status, account: user.account) }
|
||||
let(:params) do
|
||||
{
|
||||
@@ -248,7 +248,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a self-quote post and a CW but no text', feature: :outgoing_quotes do
|
||||
context 'with a self-quote post and a CW but no text' do
|
||||
let(:quoted_status) { Fabricate(:status, account: user.account) }
|
||||
let(:params) do
|
||||
{
|
||||
@@ -420,7 +420,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
context 'when updating only the quote policy' do
|
||||
let(:params) { { status: status.text, quote_approval_policy: 'public' } }
|
||||
|
||||
it 'updates the status', :aggregate_failures, feature: :outgoing_quotes do
|
||||
it 'updates the status', :aggregate_failures do
|
||||
expect { subject }
|
||||
.to change { status.reload.quote_approval_policy }.to(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ RSpec.describe ActivityPub::NoteSerializer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a quote policy', feature: :outgoing_quotes do
|
||||
context 'with a quote policy' do
|
||||
let(:parent) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) }
|
||||
|
||||
it 'has the expected shape' do
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user