Merge commit 'c48634cf5f3c5b50fcf6ea075121fa2d88e07c0e' into glitch-soc/merge-upstream
This commit is contained in:
commit
13499168f8
19
CHANGELOG.md
19
CHANGELOG.md
@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.6] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr))
|
||||
|
||||
### Changed
|
||||
|
||||
- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire)
|
||||
- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS)
|
||||
- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire)
|
||||
- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire)
|
||||
- Fix cross-server conversation tracking (#37559 by @ClearlyClaire)
|
||||
- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable)
|
||||
|
||||
## [4.5.5] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
@ -6,17 +6,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :check_authorization
|
||||
before_action :set_items
|
||||
before_action :set_size
|
||||
before_action :set_type
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
|
||||
if @unauthorized
|
||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
else
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
@unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
end
|
||||
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@ -59,11 +73,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def for_signed_account
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
if @unauthorized
|
||||
[]
|
||||
else
|
||||
yield
|
||||
|
||||
@ -153,7 +153,8 @@ export function fetchAccountFail(id, error) {
|
||||
*/
|
||||
export function followAccount(id, options = { reblogs: true }) {
|
||||
return (dispatch, getState) => {
|
||||
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
|
||||
const relationship = getState().getIn(['relationships', id]);
|
||||
const alreadyFollowing = relationship?.following || relationship?.requested;
|
||||
const locked = getState().getIn(['accounts', id, 'locked'], false);
|
||||
|
||||
dispatch(followAccountRequest({ id, locked }));
|
||||
|
||||
@ -71,10 +71,15 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { text, description, icon } = item;
|
||||
const { text, description, icon, iconId } = item;
|
||||
return (
|
||||
<>
|
||||
{icon && <Icon icon={icon} id={`${text}-icon`} />}
|
||||
{icon && (
|
||||
<Icon
|
||||
icon={icon}
|
||||
id={iconId ?? text.toLowerCase().replaceAll(/[^a-z]+/g, '-')}
|
||||
/>
|
||||
)}
|
||||
<span className='dropdown-menu__item-content'>
|
||||
{text}
|
||||
{Boolean(description) && (
|
||||
|
||||
@ -5,6 +5,7 @@ import { useIntl, defineMessages } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import {
|
||||
fetchRelationships,
|
||||
followAccount,
|
||||
@ -94,7 +95,17 @@ export const FollowButton: React.FC<{
|
||||
|
||||
if (accountId === me) {
|
||||
return;
|
||||
} else if (relationship.muting) {
|
||||
} else if (relationship.blocking) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_UNBLOCK',
|
||||
modalProps: { account },
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
relationship.muting &&
|
||||
!isClientFeatureEnabled('profile_redesign')
|
||||
) {
|
||||
dispatch(unmuteAccount(accountId));
|
||||
} else if (account && relationship.following) {
|
||||
dispatch(
|
||||
@ -107,13 +118,6 @@ export const FollowButton: React.FC<{
|
||||
modalProps: { account },
|
||||
}),
|
||||
);
|
||||
} else if (relationship.blocking) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_UNBLOCK',
|
||||
modalProps: { account },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(accountId));
|
||||
}
|
||||
@ -136,7 +140,10 @@ export const FollowButton: React.FC<{
|
||||
label = intl.formatMessage(messages.editProfile);
|
||||
} else if (!relationship) {
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.muting) {
|
||||
} else if (
|
||||
relationship.muting &&
|
||||
!isClientFeatureEnabled('profile_redesign')
|
||||
) {
|
||||
label = intl.formatMessage(messages.unmute);
|
||||
} else if (relationship.following) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
@ -173,7 +180,7 @@ export const FollowButton: React.FC<{
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
secondary={following}
|
||||
secondary={following || relationship?.blocking}
|
||||
compact={compact}
|
||||
className={classNames(className, { 'button--destructive': following })}
|
||||
>
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
.stack {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
padding: 16px;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import classes from './form_stack.module.scss';
|
||||
|
||||
/**
|
||||
* A simple wrapper for providing consistent spacing to a group of form fields.
|
||||
*/
|
||||
|
||||
export const FormStack = polymorphicForwardRef<'div'>(
|
||||
({ as: Element = 'div', children, className, ...otherProps }, ref) => (
|
||||
<Element
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.stack)}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
),
|
||||
);
|
||||
|
||||
FormStack.displayName = 'FormStack';
|
||||
@ -1,5 +1,6 @@
|
||||
export { TextInputField } from './text_input_field';
|
||||
export { TextAreaField } from './text_area_field';
|
||||
export { FormStack } from './form_stack';
|
||||
export { TextInputField, TextInput } from './text_input_field';
|
||||
export { TextAreaField, TextArea } from './text_area_field';
|
||||
export { CheckboxField, Checkbox } from './checkbox_field';
|
||||
export { ToggleField, Toggle } from './toggle_field';
|
||||
export { SelectField } from './select_field';
|
||||
export { SelectField, Select } from './select_field';
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
/* Dropdown indicator icon */
|
||||
&::after {
|
||||
--icon-size: 11px;
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-end: 9px;
|
||||
width: var(--icon-size);
|
||||
background-color: var(--color-text-tertiary);
|
||||
pointer-events: none;
|
||||
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='black'/></svg>");
|
||||
mask-position: right center;
|
||||
mask-size: var(--icon-size);
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&:has(.select:focus-visible)::after {
|
||||
background-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:has(.select:disabled)::after {
|
||||
background-color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 41px;
|
||||
padding-inline-start: 10px;
|
||||
padding-inline-end: 30px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-text-disabled);
|
||||
border-color: transparent;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-has-error='true'] & {
|
||||
border-color: var(--color-text-error);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { SelectField } from './select_field';
|
||||
import { SelectField, Select } from './select_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/SelectField',
|
||||
@ -8,24 +8,19 @@ const meta = {
|
||||
args: {
|
||||
label: 'Fruit preference',
|
||||
hint: 'Select your favourite fruit or not. Up to you.',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<SelectField {...args}>
|
||||
<option>Apple</option>
|
||||
<option>Banana</option>
|
||||
<option>Kiwi</option>
|
||||
<option>Lemon</option>
|
||||
<option>Mango</option>
|
||||
<option>Orange</option>
|
||||
<option>Pomelo</option>
|
||||
<option>Strawberries</option>
|
||||
<option>Something else</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
);
|
||||
children: (
|
||||
<>
|
||||
<option>Apple</option>
|
||||
<option>Banana</option>
|
||||
<option>Kiwi</option>
|
||||
<option>Lemon</option>
|
||||
<option>Mango</option>
|
||||
<option>Orange</option>
|
||||
<option>Pomelo</option>
|
||||
<option>Strawberries</option>
|
||||
<option>Something else</option>
|
||||
</>
|
||||
),
|
||||
},
|
||||
} satisfies Meta<typeof SelectField>;
|
||||
|
||||
@ -59,3 +54,16 @@ export const WithError: Story = {
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <Select {...args} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
...Plain,
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './select.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
||||
@ -25,14 +28,27 @@ export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<div className='select-wrapper'>
|
||||
<select {...otherProps} {...inputProps} ref={ref}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
<Select {...otherProps} {...inputProps} ref={ref}>
|
||||
{children}
|
||||
</Select>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
SelectField.displayName = 'SelectField';
|
||||
|
||||
export const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
ComponentPropsWithoutRef<'select'>
|
||||
>(({ className, size, ...otherProps }, ref) => (
|
||||
<div className={classes.wrapper}>
|
||||
<select
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.select)}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TextAreaField } from './text_area_field';
|
||||
import { TextAreaField, TextArea } from './text_area_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/TextAreaField',
|
||||
@ -9,14 +9,6 @@ const meta = {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<TextAreaField {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof TextAreaField>;
|
||||
|
||||
export default meta;
|
||||
@ -49,3 +41,17 @@ export const WithError: Story = {
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <TextArea {...args} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
...Plain,
|
||||
args: {
|
||||
disabled: true,
|
||||
defaultValue: "This value can't be changed",
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
||||
@ -23,9 +26,22 @@ export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
||||
export const TextArea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
ComponentPropsWithoutRef<'textarea'>
|
||||
>(({ className, ...otherProps }, ref) => (
|
||||
<textarea
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
outline: var(--outline-focus-default);
|
||||
outline-color: transparent;
|
||||
outline-offset: -1px;
|
||||
transition: outline-color 0.15s ease-out;
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
&:focus:user-invalid,
|
||||
&:required:user-invalid,
|
||||
[data-has-error='true'] & {
|
||||
outline-color: var(--color-text-error);
|
||||
}
|
||||
|
||||
&:required:user-valid {
|
||||
outline-color: var(--color-text-success);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-text-disabled);
|
||||
border-color: transparent;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TextInputField } from './text_input_field';
|
||||
import { TextInputField, TextInput } from './text_input_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/TextInputField',
|
||||
@ -9,14 +9,6 @@ const meta = {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<TextInputField {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof TextInputField>;
|
||||
|
||||
export default meta;
|
||||
@ -49,3 +41,17 @@ export const WithError: Story = {
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <TextInput {...args} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
...Plain,
|
||||
args: {
|
||||
disabled: true,
|
||||
defaultValue: "This value can't be changed",
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
|
||||
@ -15,10 +18,7 @@ interface Props
|
||||
*/
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
@ -26,11 +26,23 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<input type={type} {...otherProps} {...inputProps} ref={ref} />
|
||||
)}
|
||||
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextInputField.displayName = 'TextInputField';
|
||||
|
||||
export const TextInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
ComponentPropsWithoutRef<'input'>
|
||||
>(({ type = 'text', className, ...otherProps }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
@ -1,33 +1,57 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
import type { IconProp } from '../icon';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface MiniCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MiniCard: FC<MiniCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
}) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
export type MiniCardProps = OmitUnion<
|
||||
ComponentPropsWithoutRef<'div'>,
|
||||
{
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
icon?: IconProp;
|
||||
iconId?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.card, className)}
|
||||
inert={hidden ? '' : undefined}
|
||||
>
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
|
||||
(
|
||||
{ label, value, className, hidden, icon, iconId, iconClassName, ...props },
|
||||
ref,
|
||||
) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames(
|
||||
classes.card,
|
||||
icon && classes.cardWithIcon,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{icon && (
|
||||
<Icon
|
||||
id={iconId ?? 'minicard'}
|
||||
icon={icon}
|
||||
className={classNames(classes.icon, iconClassName)}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
MiniCard.displayName = 'MiniCard';
|
||||
|
||||
@ -1,69 +1,37 @@
|
||||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { forwardRef } from 'react';
|
||||
import type { ComponentPropsWithoutRef, Key } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import type { MiniCardProps as BaseCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export type MiniCardProps = BaseCardProps & {
|
||||
key?: Key;
|
||||
};
|
||||
|
||||
interface MiniCardListProps {
|
||||
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & {
|
||||
key?: Key;
|
||||
})[];
|
||||
className?: string;
|
||||
onOverflowClick?: MouseEventHandler;
|
||||
cards?: MiniCardProps[];
|
||||
}
|
||||
|
||||
export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
cards = [],
|
||||
className,
|
||||
onOverflowClick,
|
||||
}) => {
|
||||
const {
|
||||
wrapperRef,
|
||||
listRef,
|
||||
hiddenCount,
|
||||
hasOverflow,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
} = useOverflow();
|
||||
|
||||
export const MiniCardList = forwardRef<
|
||||
HTMLDListElement,
|
||||
OmitUnion<ComponentPropsWithoutRef<'dl'>, MiniCardListProps>
|
||||
>(({ cards = [], className, children, ...props }, ref) => {
|
||||
if (!cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(classes.wrapper, className)} ref={wrapperRef}>
|
||||
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard
|
||||
key={card.key ?? index}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
hidden={hasOverflow && index >= hiddenIndex}
|
||||
className={card.className}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
{cards.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(classes.more, !hasOverflow && classes.hidden)}
|
||||
onClick={onOverflowClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='minicard.more_items'
|
||||
defaultMessage='+{count}'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<dl {...props} className={classNames(classes.list, className)} ref={ref}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard key={card.key ?? index} {...card} />
|
||||
))}
|
||||
{children}
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
});
|
||||
MiniCardList.displayName = 'MiniCardList';
|
||||
|
||||
@ -1,30 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import LinkIcon from '@/material-icons/400-24px/link_2.svg?react';
|
||||
|
||||
import { MiniCardList } from './list';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/MiniCard',
|
||||
component: MiniCardList,
|
||||
args: {
|
||||
onOverflowClick: action('Overflow clicked'),
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
resize: 'horizontal',
|
||||
padding: '1rem',
|
||||
border: '1px solid gray',
|
||||
overflow: 'auto',
|
||||
width: '400px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<MiniCardList {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof MiniCardList>;
|
||||
|
||||
export default meta;
|
||||
@ -38,10 +20,12 @@ export const Default: Story = {
|
||||
{
|
||||
label: 'Website',
|
||||
value: <a href='https://example.com'>bowie-the-db.meow</a>,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
label: 'Free playlists',
|
||||
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{ label: 'Location', value: 'Purris, France' },
|
||||
],
|
||||
@ -54,11 +38,13 @@ export const LongValue: Story = {
|
||||
{
|
||||
label: 'Username',
|
||||
value: 'bowie-the-dj',
|
||||
style: { maxWidth: '250px' },
|
||||
},
|
||||
{
|
||||
label: 'Bio',
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
style: { maxWidth: '250px' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,52 +1,49 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card,
|
||||
.more {
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
column-gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
background: none;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
transition: all 300ms linear;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
color: var(--color-text-primary);
|
||||
.cardWithIcon {
|
||||
grid-template-columns: 16px 1fr;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
.icon {
|
||||
grid-row: span 2;
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align-self: center;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
|
||||
a {
|
||||
color: var(--color-text-brand);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--color-text-brand-soft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label,
|
||||
@ -54,4 +51,8 @@
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.cardWithIcon & {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import { Select } from '@/mastodon/components/form_fields';
|
||||
import type { RootState } from '@/mastodon/store';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@ -104,19 +105,17 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
</label>
|
||||
<div className='select-wrapper'>
|
||||
<select onChange={handleLocaleChange} id='language-select'>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === selectedLocale}
|
||||
>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Select onChange={handleLocaleChange} id='language-select'>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === selectedLocale}
|
||||
>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { RefCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
@ -77,6 +78,40 @@ export const AccountHeader: React.FC<{
|
||||
[dispatch, account],
|
||||
);
|
||||
|
||||
const [isFooterIntersecting, setIsIntersecting] = useState(false);
|
||||
const handleIntersect: IntersectionObserverCallback = useCallback(
|
||||
(entries) => {
|
||||
const entry = entries.at(0);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsIntersecting(entry.isIntersecting);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [observer] = useState(
|
||||
() =>
|
||||
new IntersectionObserver(handleIntersect, {
|
||||
rootMargin: '0px 0px -55px 0px', // Height of bottom nav bar.
|
||||
}),
|
||||
);
|
||||
|
||||
const handleObserverRef: RefCallback<HTMLDivElement> = useCallback(
|
||||
(node) => {
|
||||
if (node) {
|
||||
observer.observe(node);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [observer]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
@ -114,7 +149,12 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__header__bar'>
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__bar',
|
||||
isRedesignEnabled() && redesignClasses.barWrapper,
|
||||
)}
|
||||
>
|
||||
<div className='account__header__tabs'>
|
||||
<a
|
||||
className='avatar'
|
||||
@ -144,7 +184,13 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
>
|
||||
<AccountName accountId={accountId} />
|
||||
{isRedesignEnabled() && <AccountButtons accountId={accountId} />}
|
||||
{isRedesignEnabled() && (
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
className={redesignClasses.buttonsDesktop}
|
||||
noShare
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountBadges accountId={accountId} />
|
||||
@ -153,11 +199,13 @@ export const AccountHeader: React.FC<{
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
{!isRedesignEnabled() && (
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
)}
|
||||
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
@ -181,10 +229,22 @@ export const AccountHeader: React.FC<{
|
||||
<AccountNumberFields accountId={accountId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRedesignEnabled() && (
|
||||
<AccountButtons
|
||||
className={classNames(
|
||||
redesignClasses.buttonsMobile,
|
||||
!isFooterIntersecting && redesignClasses.buttonsMobileIsStuck,
|
||||
)}
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AnimateEmojiProvider>
|
||||
|
||||
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
|
||||
<div ref={handleObserverRef} />
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
|
||||
@ -108,48 +108,6 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
className={classNames(className, classes.badgeMuted)}
|
||||
/>,
|
||||
);
|
||||
} else if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
badges.push(
|
||||
<Badge
|
||||
key='mutuals-badge'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.badges.mutuals'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
}
|
||||
className={className}
|
||||
/>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
badges.push(
|
||||
<Badge
|
||||
key='follows-you-badge'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.badges.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
}
|
||||
className={className}
|
||||
/>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
badges.push(
|
||||
<Badge
|
||||
key='requested-to-follow-badge'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.badges.requested_to_follow'
|
||||
defaultMessage='Requested to follow you'
|
||||
/>
|
||||
}
|
||||
className={className}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,21 +1,23 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { MiniCardList } from '@/mastodon/components/mini_card/list';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { MiniCard } from '@/mastodon/components/mini_card';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useOverflowScroll } from '@/mastodon/hooks/useOverflow';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import { isValidUrl } from '@/mastodon/utils/checks';
|
||||
import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import IconLink from '@/material-icons/400-24px/link_2.svg?react';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
@ -57,61 +59,94 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
|
||||
|
||||
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
account.fields
|
||||
.toArray()
|
||||
.map(({ value_emojified, name_emojified, verified_at }) => ({
|
||||
label: (
|
||||
<>
|
||||
<EmojiHTML
|
||||
htmlString={name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
as='span'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
value: (
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
),
|
||||
className: classNames(
|
||||
classes.fieldCard,
|
||||
!!verified_at && classes.fieldCardVerified,
|
||||
),
|
||||
})),
|
||||
[account.emojis, account.fields, htmlHandlers],
|
||||
);
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOverflowClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_FIELDS',
|
||||
modalProps: { accountId: account.id },
|
||||
}),
|
||||
);
|
||||
}, [account.id, dispatch]);
|
||||
const {
|
||||
bodyRef,
|
||||
canScrollLeft,
|
||||
canScrollRight,
|
||||
handleLeftNav,
|
||||
handleRightNav,
|
||||
handleScroll,
|
||||
} = useOverflowScroll();
|
||||
|
||||
return (
|
||||
<MiniCardList
|
||||
cards={cards}
|
||||
className={classes.fieldList}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
classes.fieldWrapper,
|
||||
canScrollLeft && classes.fieldWrapperLeft,
|
||||
canScrollRight && classes.fieldWrapperRight,
|
||||
)}
|
||||
>
|
||||
{canScrollLeft && (
|
||||
<IconButton
|
||||
icon='more'
|
||||
iconComponent={IconLeftArrow}
|
||||
title={intl.formatMessage({
|
||||
id: 'account.fields.scroll_prev',
|
||||
defaultMessage: 'Show previous',
|
||||
})}
|
||||
className={classes.fieldArrowButton}
|
||||
onClick={handleLeftNav}
|
||||
/>
|
||||
)}
|
||||
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
|
||||
{account.fields.map(
|
||||
(
|
||||
{ name, name_emojified, value_emojified, value_plain, verified_at },
|
||||
key,
|
||||
) => (
|
||||
<MiniCard
|
||||
key={key}
|
||||
label={
|
||||
<EmojiHTML
|
||||
htmlString={name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
as='span'
|
||||
title={name}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
}
|
||||
value={
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
title={value_plain ?? undefined}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
}
|
||||
icon={fieldIcon(verified_at, value_plain)}
|
||||
className={classNames(
|
||||
classes.fieldCard,
|
||||
verified_at && classes.fieldCardVerified,
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</dl>
|
||||
{canScrollRight && (
|
||||
<IconButton
|
||||
icon='more'
|
||||
iconComponent={IconRightArrow}
|
||||
title={intl.formatMessage({
|
||||
id: 'account.fields.scroll_next',
|
||||
defaultMessage: 'Show next',
|
||||
})}
|
||||
className={classes.fieldArrowButton}
|
||||
onClick={handleRightNav}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function fieldIcon(verified_at: string | null, value_plain: string | null) {
|
||||
if (verified_at) {
|
||||
return IconVerified;
|
||||
} else if (value_plain && isValidUrl(value_plain)) {
|
||||
return IconLink;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountFieldsModal: FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
icon='close'
|
||||
className={classes.modalCloseButton}
|
||||
onClick={onClose}
|
||||
iconComponent={CloseIcon}
|
||||
title={intl.formatMessage({
|
||||
id: 'account_fields_modal.close',
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
/>
|
||||
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
|
||||
<FormattedMessage
|
||||
id='account_fields_modal.title'
|
||||
defaultMessage="{name}'s info"
|
||||
values={{
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='dialog-modal__content'>
|
||||
<AnimateEmojiProvider>
|
||||
<dl className={classes.modalFieldsList}>
|
||||
{account.fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
|
||||
>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={field.name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<dd>
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={field.value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!field.verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</AnimateEmojiProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -12,6 +12,7 @@ import {
|
||||
unpinAccount,
|
||||
} from '@/mastodon/actions/accounts';
|
||||
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
|
||||
import { showAlert } from '@/mastodon/actions/alerts';
|
||||
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
@ -23,16 +24,78 @@ import { initReport } from '@/mastodon/actions/reports';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown_menu';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
import {
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
PERMISSION_MANAGE_USERS,
|
||||
} from '@/mastodon/permissions';
|
||||
import type { AppDispatch } from '@/mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
|
||||
import LinkIcon from '@/material-icons/400-24px/link_2.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
|
||||
import ReportIcon from '@/material-icons/400-24px/report.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
if (!account) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
return redesignMenuItems({
|
||||
account,
|
||||
signedIn,
|
||||
permissions,
|
||||
intl,
|
||||
relationship,
|
||||
dispatch,
|
||||
});
|
||||
}
|
||||
return currentMenuItems({
|
||||
account,
|
||||
signedIn,
|
||||
permissions,
|
||||
intl,
|
||||
relationship,
|
||||
dispatch,
|
||||
});
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface MenuItemsParams {
|
||||
account: Account;
|
||||
signedIn: boolean;
|
||||
permissions: number;
|
||||
intl: ReturnType<typeof useIntl>;
|
||||
relationship?: Relationship;
|
||||
dispatch: AppDispatch;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
@ -109,80 +172,78 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
function currentMenuItems({
|
||||
account,
|
||||
signedIn,
|
||||
permissions,
|
||||
intl,
|
||||
relationship,
|
||||
dispatch,
|
||||
}: MenuItemsParams): MenuItem[] {
|
||||
const items: MenuItem[] = [];
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
if (signedIn && !account.suspended) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
if (isRemote) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return arr;
|
||||
}
|
||||
if (!signedIn) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
if (relationship?.following) {
|
||||
// Timeline options
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
@ -194,13 +255,295 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
arr.push({
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmRemoveFromFollowersTitle),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(
|
||||
removeAccountFromFollowers({ accountId: account.id }),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
items.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
items.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const redesignMessages = defineMessages({
|
||||
share: { id: 'account.menu.share', defaultMessage: 'Share…' },
|
||||
copy: { id: 'account.menu.copy', defaultMessage: 'Copy link' },
|
||||
copied: {
|
||||
id: 'account.menu.copied',
|
||||
defaultMessage: 'Copied account link to clipboard',
|
||||
},
|
||||
mention: { id: 'account.menu.mention', defaultMessage: 'Mention' },
|
||||
direct: {
|
||||
id: 'account.menu.direct',
|
||||
defaultMessage: 'Privately mention',
|
||||
},
|
||||
mute: { id: 'account.menu.mute', defaultMessage: 'Mute account' },
|
||||
unmute: {
|
||||
id: 'account.menu.unmute',
|
||||
defaultMessage: 'Unmute account',
|
||||
},
|
||||
block: { id: 'account.menu.block', defaultMessage: 'Block account' },
|
||||
unblock: {
|
||||
id: 'account.menu.unblock',
|
||||
defaultMessage: 'Unblock account',
|
||||
},
|
||||
domainBlock: {
|
||||
id: 'account.menu.block_domain',
|
||||
defaultMessage: 'Block {domain}',
|
||||
},
|
||||
domainUnblock: {
|
||||
id: 'account.menu.unblock_domain',
|
||||
defaultMessage: 'Unblock {domain}',
|
||||
},
|
||||
report: { id: 'account.menu.report', defaultMessage: 'Report account' },
|
||||
hideReblogs: {
|
||||
id: 'account.menu.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts in timeline',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.menu.show_reblogs',
|
||||
defaultMessage: 'Show boosts in timeline',
|
||||
},
|
||||
addToList: {
|
||||
id: 'account.menu.add_to_list',
|
||||
defaultMessage: 'Add to list…',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.menu.open_original_page',
|
||||
defaultMessage: 'View on {domain}',
|
||||
},
|
||||
removeFollower: {
|
||||
id: 'account.menu.remove_follower',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
function redesignMenuItems({
|
||||
account,
|
||||
signedIn,
|
||||
permissions,
|
||||
intl,
|
||||
relationship,
|
||||
dispatch,
|
||||
}: MenuItemsParams): MenuItem[] {
|
||||
const items: MenuItem[] = [];
|
||||
const isRemote = account.acct !== account.username;
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
|
||||
// Share and copy link options
|
||||
if (account.url) {
|
||||
if ('share' in navigator) {
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.share),
|
||||
action: () => {
|
||||
void navigator.share({
|
||||
url: account.url,
|
||||
});
|
||||
},
|
||||
icon: ShareIcon,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.copy),
|
||||
action: () => {
|
||||
void navigator.clipboard.writeText(account.url);
|
||||
dispatch(showAlert({ message: redesignMessages.copied }));
|
||||
},
|
||||
icon: LinkIcon,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// Mention and direct message options
|
||||
if (signedIn && !account.suspended) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.mention),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.direct),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
},
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship?.note ? messages.editNote : messages.addNote,
|
||||
),
|
||||
@ -214,27 +557,34 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!relationship?.following) {
|
||||
arr.push(null);
|
||||
}
|
||||
}
|
||||
icon: EditIcon,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
// Open on remote page.
|
||||
if (isRemote) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.openOriginalPage, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: account.url,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// List and featuring options
|
||||
if (relationship?.following) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.addToList),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
@ -245,12 +595,76 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
// Timeline options
|
||||
if (!relationship.muting) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.showing_reblogs
|
||||
? redesignMessages.hideReblogs
|
||||
: redesignMessages.showReblogs,
|
||||
),
|
||||
action: () => {
|
||||
dispatch(
|
||||
followAccount(account.id, {
|
||||
reblogs: !relationship.showing_reblogs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.muting ? redesignMessages.unmute : redesignMessages.mute,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.muting) {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.removeFollower),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
@ -273,134 +687,91 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
dangerous: true,
|
||||
icon: PersonRemoveIcon,
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
text: intl.formatMessage(
|
||||
relationship?.blocking
|
||||
? redesignMessages.unblock
|
||||
: redesignMessages.block,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(account.id));
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
dispatch(blockAccount(account.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
dangerous: true,
|
||||
icon: BlockIcon,
|
||||
});
|
||||
|
||||
if (!account.suspended) {
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.report),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
icon: ReportIcon,
|
||||
});
|
||||
}
|
||||
|
||||
if (remoteDomain) {
|
||||
items.push({
|
||||
text: intl.formatMessage(
|
||||
relationship?.domain_blocking
|
||||
? redesignMessages.domainUnblock
|
||||
: redesignMessages.domainBlock,
|
||||
{
|
||||
domain: remoteDomain,
|
||||
},
|
||||
),
|
||||
action: () => {
|
||||
if (relationship?.domain_blocking) {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
} else {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
}
|
||||
},
|
||||
dangerous: true,
|
||||
icon: BlockIcon,
|
||||
iconId: 'domain-block',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
items.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
remoteDomain &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return items;
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
.barWrapper {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.nameWrapper {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@ -45,6 +49,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
|
||||
|
||||
.buttonsDesktop {
|
||||
@container (width < #{$button-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (max-width: #{$button-fallback-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsMobile {
|
||||
position: sticky;
|
||||
bottom: 55px; // Height of bottom nav bar.
|
||||
|
||||
@container (width >= #{$button-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsMobileIsStuck {
|
||||
padding: 12px 16px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
margin: 0 -20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
@ -84,36 +125,112 @@ svg.badgeIcon {
|
||||
}
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
.fieldWrapper {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fieldWrapper::before,
|
||||
.fieldWrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fieldWrapper::before {
|
||||
left: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
transparent 0%,
|
||||
var(--color-bg-primary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.fieldWrapper::after {
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
var(--color-bg-primary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.fieldWrapperLeft::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fieldWrapperRight::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-left: 40px;
|
||||
scroll-padding-right: 40px;
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.fieldCard {
|
||||
position: relative;
|
||||
scroll-snap-align: start;
|
||||
|
||||
a {
|
||||
color: var(--color-text-brand);
|
||||
text-decoration: none;
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
:is(dt, dd) {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldCardVerified {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
|
||||
dt {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
.fieldArrowButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: var(--color-bg-elevated);
|
||||
box-shadow: 0 1px 4px 0 var(--color-shadow-primary);
|
||||
border-radius: 9999px;
|
||||
transition:
|
||||
color 0.2s ease-in-out,
|
||||
background-color 0.2s ease-in-out;
|
||||
outline-offset: 2px;
|
||||
z-index: 2;
|
||||
|
||||
&:first-child {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--color-bg-brand-base) var(--overlay-strength-brand),
|
||||
var(--color-bg-elevated)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldNumbersWrapper {
|
||||
|
||||
@ -9,7 +9,7 @@ import { useParams } from 'react-router';
|
||||
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
|
||||
import { useAppHistory } from '@/mastodon/components/router';
|
||||
import { Tag } from '@/mastodon/components/tags/tag';
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
import { useOverflowButton } from '@/mastodon/hooks/useOverflow';
|
||||
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@ -30,7 +30,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
// Get list of tags with overflow handling.
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
|
||||
useOverflow();
|
||||
useOverflowButton();
|
||||
|
||||
// Handle whether to show all tags.
|
||||
const handleOverflowClick: MouseEventHandler = useCallback(() => {
|
||||
|
||||
@ -16,7 +16,11 @@ import type {
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { CheckboxField, TextAreaField } from 'mastodon/components/form_fields';
|
||||
import {
|
||||
CheckboxField,
|
||||
FormStack,
|
||||
TextAreaField,
|
||||
} from 'mastodon/components/form_fields';
|
||||
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import {
|
||||
@ -129,88 +133,80 @@ const CollectionSettings: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||
<div className='fields-group'>
|
||||
<TextInputField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.name_length_hint'
|
||||
defaultMessage='40 characters limit'
|
||||
/>
|
||||
}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<FormStack as='form' onSubmit={handleSubmit}>
|
||||
<TextInputField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.name_length_hint'
|
||||
defaultMessage='40 characters limit'
|
||||
/>
|
||||
}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
|
||||
<div className='fields-group'>
|
||||
<TextAreaField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_description'
|
||||
defaultMessage='Description'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.description_length_hint'
|
||||
defaultMessage='100 characters limit'
|
||||
/>
|
||||
}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_description'
|
||||
defaultMessage='Description'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.description_length_hint'
|
||||
defaultMessage='100 characters limit'
|
||||
/>
|
||||
}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
maxLength={100}
|
||||
/>
|
||||
|
||||
<div className='fields-group'>
|
||||
<TextInputField
|
||||
required={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_topic'
|
||||
defaultMessage='Topic'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.topic_hint'
|
||||
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
|
||||
/>
|
||||
}
|
||||
value={topic}
|
||||
onChange={handleTopicChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<TextInputField
|
||||
required={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_topic'
|
||||
defaultMessage='Topic'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.topic_hint'
|
||||
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
|
||||
/>
|
||||
}
|
||||
value={topic}
|
||||
onChange={handleTopicChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
|
||||
<div className='fields-group'>
|
||||
<CheckboxField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive'
|
||||
defaultMessage='Mark as sensitive'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive_hint'
|
||||
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
|
||||
/>
|
||||
}
|
||||
checked={sensitive}
|
||||
onChange={handleSensitiveChange}
|
||||
/>
|
||||
</div>
|
||||
<CheckboxField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive'
|
||||
defaultMessage='Mark as sensitive'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive_hint'
|
||||
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
|
||||
/>
|
||||
}
|
||||
checked={sensitive}
|
||||
onChange={handleSensitiveChange}
|
||||
/>
|
||||
|
||||
<div className='actions'>
|
||||
<Button type='submit'>
|
||||
@ -221,7 +217,7 @@ const CollectionSettings: React.FC<{
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useEffect, useCallback, useRef, useState, useId } from 'react';
|
||||
import { useEffect, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useOverflowScroll } from '@/mastodon/hooks/useOverflow';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
@ -178,74 +179,24 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
|
||||
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
|
||||
);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft -= 200;
|
||||
}, []);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft += 200;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
||||
}, [dispatch]);
|
||||
|
||||
const {
|
||||
bodyRef,
|
||||
handleScroll,
|
||||
canScrollLeft,
|
||||
canScrollRight,
|
||||
handleLeftNav,
|
||||
handleRightNav,
|
||||
} = useOverflowScroll({ absoluteDistance: true });
|
||||
|
||||
if (dismissed || (!isLoading && suggestions.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -88,7 +88,6 @@ export const MODAL_COMPONENTS = {
|
||||
'ANNUAL_REPORT': AnnualReportModal,
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||
'ACCOUNT_FIELDS': () => import('mastodon/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import type { MutableRefObject, RefCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate and manage overflow of child elements within a container.
|
||||
* Hook to manage overflow of items in a container with a "more" button.
|
||||
*
|
||||
* To use, wire up the `wrapperRef` to the container element, and the `listRef` to the
|
||||
* child element that contains the items to be measured. If autoResize is true,
|
||||
* the list element will have its max-width set to prevent wrapping. The listRef element
|
||||
* requires both position:relative and overflow:hidden styles to work correctly.
|
||||
*/
|
||||
export function useOverflow({
|
||||
export function useOverflowButton({
|
||||
autoResize,
|
||||
padding = 4,
|
||||
}: { autoResize?: boolean; padding?: number } = {}) {
|
||||
@ -76,6 +77,111 @@ export function useOverflow({
|
||||
}
|
||||
}, [autoResize, maxWidth]);
|
||||
|
||||
const { listRefCallback, wrapperRefCallback } = useOverflowObservers({
|
||||
onRecalculate: handleRecalculate,
|
||||
onListRef: listRef,
|
||||
});
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useOverflowScroll({
|
||||
widthOffset = 200,
|
||||
absoluteDistance = false,
|
||||
} = {}) {
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const bodyRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Recalculate scrollable state
|
||||
const handleRecalculate = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { wrapperRefCallback } = useOverflowObservers({
|
||||
onRecalculate: handleRecalculate,
|
||||
onWrapperRef: bodyRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Handle scroll event using requestAnimationFrame to avoid excessive recalculations.
|
||||
const handleScroll = useCallback(() => {
|
||||
requestAnimationFrame(handleRecalculate);
|
||||
}, [handleRecalculate]);
|
||||
|
||||
// Jump a full screen minus the width offset so that we don't skip a lot.
|
||||
const handleLeftNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft -= absoluteDistance
|
||||
? widthOffset
|
||||
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
|
||||
}, [absoluteDistance, widthOffset]);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft += absoluteDistance
|
||||
? widthOffset
|
||||
: Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset);
|
||||
}, [absoluteDistance, widthOffset]);
|
||||
|
||||
return {
|
||||
bodyRef: wrapperRefCallback,
|
||||
canScrollLeft,
|
||||
canScrollRight,
|
||||
handleLeftNav,
|
||||
handleRightNav,
|
||||
handleScroll,
|
||||
};
|
||||
}
|
||||
|
||||
export function useOverflowObservers({
|
||||
onRecalculate,
|
||||
onListRef,
|
||||
onWrapperRef,
|
||||
}: {
|
||||
onRecalculate: () => void;
|
||||
onListRef?: RefCallback<HTMLElement> | MutableRefObject<HTMLElement | null>;
|
||||
onWrapperRef?:
|
||||
| RefCallback<HTMLElement>
|
||||
| MutableRefObject<HTMLElement | null>;
|
||||
}) {
|
||||
// This is the item container element.
|
||||
const listRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Set up observers to watch for size and content changes.
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||
@ -83,10 +189,10 @@ export function useOverflow({
|
||||
// Helper to get or create the resize observer.
|
||||
const resizeObserver = useCallback(() => {
|
||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||
handleRecalculate,
|
||||
onRecalculate,
|
||||
));
|
||||
return observer;
|
||||
}, [handleRecalculate]);
|
||||
}, [onRecalculate]);
|
||||
|
||||
// Iterate through children and observe them for size changes.
|
||||
const handleChildrenChange = useCallback(() => {
|
||||
@ -100,8 +206,8 @@ export function useOverflow({
|
||||
}
|
||||
}
|
||||
}
|
||||
handleRecalculate();
|
||||
}, [handleRecalculate, resizeObserver]);
|
||||
onRecalculate();
|
||||
}, [onRecalculate, resizeObserver]);
|
||||
|
||||
// Helper to get or create the mutation observer.
|
||||
const mutationObserver = useCallback(() => {
|
||||
@ -129,9 +235,14 @@ export function useOverflow({
|
||||
if (node) {
|
||||
wrapperRef.current = node;
|
||||
handleObserve();
|
||||
if (typeof onWrapperRef === 'function') {
|
||||
onWrapperRef(node);
|
||||
} else if (onWrapperRef && 'current' in onWrapperRef) {
|
||||
onWrapperRef.current = node;
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
[handleObserve, onWrapperRef],
|
||||
);
|
||||
|
||||
// If there are changes to the children, recalculate which are visible.
|
||||
@ -140,9 +251,14 @@ export function useOverflow({
|
||||
if (node) {
|
||||
listRef.current = node;
|
||||
handleObserve();
|
||||
if (typeof onListRef === 'function') {
|
||||
onListRef(node);
|
||||
} else if (onListRef && 'current' in onListRef) {
|
||||
onListRef.current = node;
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleObserve],
|
||||
[handleObserve, onListRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -161,12 +277,7 @@ export function useOverflow({
|
||||
}, [handleObserve]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
hasOverflow: hiddenCount > 0,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
listRef: listRefCallback,
|
||||
recalculate: handleRecalculate,
|
||||
wrapperRefCallback,
|
||||
listRefCallback,
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Актыўнасць",
|
||||
"account.add_note": "Дадаць асабістую нататку",
|
||||
"account.add_or_remove_from_list": "Дадаць або выдаліць са спісаў",
|
||||
"account.badges.admin": "Адмін",
|
||||
"account.badges.blocked": "Заблакіраваны",
|
||||
"account.badges.bot": "Бот",
|
||||
"account.badges.domain_blocked": "Заблакіраваны дамен",
|
||||
"account.badges.follows_you": "Падпісаны(-ая) на Вас",
|
||||
"account.badges.group": "Група",
|
||||
"account.badges.muted": "Ігнаруецца",
|
||||
"account.badges.mutuals": "Вы падпісаныя адно на аднаго",
|
||||
"account.badges.requested_to_follow": "Адправіў(-ла) запыт на падпіску",
|
||||
"account.block": "Заблакіраваць @{name}",
|
||||
"account.block_domain": "Заблакіраваць дамен {domain}",
|
||||
"account.block_short": "Заблакіраваць",
|
||||
@ -79,7 +86,7 @@
|
||||
"account.mute_short": "Ігнараваць",
|
||||
"account.muted": "Ігнаруецца",
|
||||
"account.muting": "Ігнараванне",
|
||||
"account.mutual": "Вы падпісаны адно на аднаго",
|
||||
"account.mutual": "Вы падпісаныя адно на аднаго",
|
||||
"account.no_bio": "Апісанне адсутнічае.",
|
||||
"account.node_modal.callout": "Асабістыя нататкі бачныя толькі Вам.",
|
||||
"account.node_modal.edit_title": "Рэдагаваць асабістую нататку",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Aktivitet",
|
||||
"account.add_note": "Tilføj en personlig note",
|
||||
"account.add_or_remove_from_list": "Tilføj eller fjern fra lister",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Blokeret",
|
||||
"account.badges.bot": "Automatisert",
|
||||
"account.badges.domain_blocked": "Blokeret domæne",
|
||||
"account.badges.follows_you": "Følger dig",
|
||||
"account.badges.group": "Gruppe",
|
||||
"account.badges.muted": "Skjult",
|
||||
"account.badges.mutuals": "I følger hinanden",
|
||||
"account.badges.requested_to_follow": "Anmodede om at følge dig",
|
||||
"account.block": "Blokér @{name}",
|
||||
"account.block_domain": "Blokér domænet {domain}",
|
||||
"account.block_short": "Bloker",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Aktivitäten",
|
||||
"account.add_note": "Persönliche Notiz hinzufügen",
|
||||
"account.add_or_remove_from_list": "Listen verwalten",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Blockiert",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Domain blockiert",
|
||||
"account.badges.follows_you": "Folgt dir",
|
||||
"account.badges.group": "Gruppe",
|
||||
"account.badges.muted": "Stummgeschaltet",
|
||||
"account.badges.mutuals": "Ihr folgt einander",
|
||||
"account.badges.requested_to_follow": "Möchte dir folgen",
|
||||
"account.block": "@{name} blockieren",
|
||||
"account.block_domain": "{domain} blockieren",
|
||||
"account.block_short": "Blockieren",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Δραστηριότητα",
|
||||
"account.add_note": "Προσθέστε μια προσωπική σημείωση",
|
||||
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
|
||||
"account.badges.admin": "Διαχειριστής",
|
||||
"account.badges.blocked": "Αποκλεισμένος",
|
||||
"account.badges.bot": "Αυτοματοποιημένος",
|
||||
"account.badges.domain_blocked": "Αποκλεισμένος τομέας",
|
||||
"account.badges.follows_you": "Σε ακολουθεί",
|
||||
"account.badges.group": "Ομάδα",
|
||||
"account.badges.muted": "Σε σίγαση",
|
||||
"account.badges.mutuals": "Ακολουθείτε ο ένας τον άλλο",
|
||||
"account.badges.requested_to_follow": "Ζήτησε να σε ακολουθήσει",
|
||||
"account.block": "Αποκλεισμός @{name}",
|
||||
"account.block_domain": "Αποκλεισμός τομέα {domain}",
|
||||
"account.block_short": "Αποκλεισμός",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Activity",
|
||||
"account.add_note": "Add a personal note",
|
||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Blocked",
|
||||
"account.badges.bot": "Automated",
|
||||
"account.badges.domain_blocked": "Blocked domain",
|
||||
"account.badges.follows_you": "Follows you",
|
||||
"account.badges.group": "Group",
|
||||
"account.badges.muted": "Muted",
|
||||
"account.badges.mutuals": "You follow each other",
|
||||
"account.badges.requested_to_follow": "Requested to follow you",
|
||||
"account.block": "Block @{name}",
|
||||
"account.block_domain": "Block domain {domain}",
|
||||
"account.block_short": "Block",
|
||||
|
||||
@ -21,11 +21,8 @@
|
||||
"account.badges.blocked": "Blocked",
|
||||
"account.badges.bot": "Automated",
|
||||
"account.badges.domain_blocked": "Blocked domain",
|
||||
"account.badges.follows_you": "Follows you",
|
||||
"account.badges.group": "Group",
|
||||
"account.badges.muted": "Muted",
|
||||
"account.badges.mutuals": "You follow each other",
|
||||
"account.badges.requested_to_follow": "Requested to follow you",
|
||||
"account.block": "Block @{name}",
|
||||
"account.block_domain": "Block domain {domain}",
|
||||
"account.block_short": "Block",
|
||||
@ -49,6 +46,8 @@
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.fields.scroll_next": "Show next",
|
||||
"account.fields.scroll_prev": "Show previous",
|
||||
"account.filters.all": "All activity",
|
||||
"account.filters.boosts_toggle": "Show boosts",
|
||||
"account.filters.posts_boosts": "Posts and boosts",
|
||||
@ -80,6 +79,23 @@
|
||||
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Mention @{name}",
|
||||
"account.menu.add_to_list": "Add to list…",
|
||||
"account.menu.block": "Block account",
|
||||
"account.menu.block_domain": "Block {domain}",
|
||||
"account.menu.copied": "Copied account link to clipboard",
|
||||
"account.menu.copy": "Copy link",
|
||||
"account.menu.direct": "Privately mention",
|
||||
"account.menu.hide_reblogs": "Hide boosts in timeline",
|
||||
"account.menu.mention": "Mention",
|
||||
"account.menu.mute": "Mute account",
|
||||
"account.menu.open_original_page": "View on {domain}",
|
||||
"account.menu.remove_follower": "Remove follower",
|
||||
"account.menu.report": "Report account",
|
||||
"account.menu.share": "Share…",
|
||||
"account.menu.show_reblogs": "Show boosts in timeline",
|
||||
"account.menu.unblock": "Unblock account",
|
||||
"account.menu.unblock_domain": "Unblock {domain}",
|
||||
"account.menu.unmute": "Unmute account",
|
||||
"account.moved_to": "{name} has indicated that their new account is now:",
|
||||
"account.mute": "Mute @{name}",
|
||||
"account.mute_notifications_short": "Mute notifications",
|
||||
@ -115,8 +131,6 @@
|
||||
"account.unmute": "Unmute @{name}",
|
||||
"account.unmute_notifications_short": "Unmute notifications",
|
||||
"account.unmute_short": "Unmute",
|
||||
"account_fields_modal.close": "Close",
|
||||
"account_fields_modal.title": "{name}'s info",
|
||||
"account_note.placeholder": "Click to add note",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
@ -639,7 +653,6 @@
|
||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||
"loading_indicator.label": "Loading…",
|
||||
"media_gallery.hide": "Hide",
|
||||
"minicard.more_items": "+{count}",
|
||||
"moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Hide from notifications",
|
||||
"mute_modal.hide_options": "Hide options",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Actividad",
|
||||
"account.add_note": "Agregar una nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
|
||||
"account.badges.admin": "Administración",
|
||||
"account.badges.blocked": "Cuenta bloqueada",
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.domain_blocked": "Dominio bloqueado",
|
||||
"account.badges.follows_you": "Te sigue",
|
||||
"account.badges.group": "Grupo",
|
||||
"account.badges.muted": "Cuenta silenciada",
|
||||
"account.badges.mutuals": "Seguimiento mutuo",
|
||||
"account.badges.requested_to_follow": "Solicitó seguirte",
|
||||
"account.block": "Bloquear a @{name}",
|
||||
"account.block_domain": "Bloquear dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Actividad",
|
||||
"account.add_note": "Añadir una nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o eliminar de las listas",
|
||||
"account.badges.admin": "Administrador",
|
||||
"account.badges.blocked": "Bloqueado",
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.domain_blocked": "Dominio bloqueado",
|
||||
"account.badges.follows_you": "Te sigue",
|
||||
"account.badges.group": "Grupo",
|
||||
"account.badges.muted": "Silenciado",
|
||||
"account.badges.mutuals": "Se siguen el uno al otro",
|
||||
"account.badges.requested_to_follow": "Solicitó seguirte",
|
||||
"account.block": "Bloquear a @{name}",
|
||||
"account.block_domain": "Bloquear dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Tegevus",
|
||||
"account.add_note": "Lisa isiklik märge",
|
||||
"account.add_or_remove_from_list": "Lisa või eemalda loenditest",
|
||||
"account.badges.admin": "Peakasutaja",
|
||||
"account.badges.blocked": "Blokeeritud",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.domain_blocked": "Blokeeritud domeen",
|
||||
"account.badges.follows_you": "Jälgib sind",
|
||||
"account.badges.group": "Grupp",
|
||||
"account.badges.muted": "Summutatud",
|
||||
"account.badges.mutuals": "Te jälgite teineteist",
|
||||
"account.badges.requested_to_follow": "Soovib sind jälgida",
|
||||
"account.block": "Blokeeri @{name}",
|
||||
"account.block_domain": "Blokeeri kõik domeenist {domain}",
|
||||
"account.block_short": "Blokeerimine",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Toiminta",
|
||||
"account.add_note": "Lisää henkilökohtainen muistiinpano",
|
||||
"account.add_or_remove_from_list": "Lisää tai poista listoista",
|
||||
"account.badges.admin": "Ylläpitäjä",
|
||||
"account.badges.blocked": "Estetty",
|
||||
"account.badges.bot": "Botti",
|
||||
"account.badges.domain_blocked": "Estetty verkkotunnus",
|
||||
"account.badges.follows_you": "Seuraa sinua",
|
||||
"account.badges.group": "Ryhmä",
|
||||
"account.badges.muted": "Mykistetty",
|
||||
"account.badges.mutuals": "Seuraatte toisianne",
|
||||
"account.badges.requested_to_follow": "Pyytänyt lupaa seurata sinua",
|
||||
"account.block": "Estä @{name}",
|
||||
"account.block_domain": "Estä verkkotunnus {domain}",
|
||||
"account.block_short": "Estä",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Virksemi",
|
||||
"account.add_note": "Legg persónliga notu afturat",
|
||||
"account.add_or_remove_from_list": "Legg afturat ella tak av listum",
|
||||
"account.badges.admin": "Umsitari",
|
||||
"account.badges.blocked": "Blokerað/ur",
|
||||
"account.badges.bot": "Bottur",
|
||||
"account.badges.domain_blocked": "Blokerað økisnavn",
|
||||
"account.badges.follows_you": "Fylgir tær",
|
||||
"account.badges.group": "Bólkur",
|
||||
"account.badges.muted": "Doyvdur",
|
||||
"account.badges.mutuals": "Tit fylgja hvønn annan",
|
||||
"account.badges.requested_to_follow": "Hevur biðið um at fylgja tær",
|
||||
"account.block": "Banna @{name}",
|
||||
"account.block_domain": "Banna økisnavnið {domain}",
|
||||
"account.block_short": "Banna",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Activités",
|
||||
"account.add_note": "Ajouter une note personnelle",
|
||||
"account.add_or_remove_from_list": "Ajouter ou enlever de listes",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Bloqué",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Domaine bloqué",
|
||||
"account.badges.follows_you": "Vous suit",
|
||||
"account.badges.group": "Groupe",
|
||||
"account.badges.muted": "Masqué",
|
||||
"account.badges.mutuals": "Vous vous suivez mutuellement",
|
||||
"account.badges.requested_to_follow": "A demandé à vous suivre",
|
||||
"account.block": "Bloquer @{name}",
|
||||
"account.block_domain": "Bloquer le domaine {domain}",
|
||||
"account.block_short": "Bloquer",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Activités",
|
||||
"account.add_note": "Ajouter une note personnelle",
|
||||
"account.add_or_remove_from_list": "Ajouter ou retirer des listes",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Bloqué",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Domaine bloqué",
|
||||
"account.badges.follows_you": "Vous suit",
|
||||
"account.badges.group": "Groupe",
|
||||
"account.badges.muted": "Masqué",
|
||||
"account.badges.mutuals": "Vous vous suivez mutuellement",
|
||||
"account.badges.requested_to_follow": "A demandé à vous suivre",
|
||||
"account.block": "Bloquer @{name}",
|
||||
"account.block_domain": "Bloquer le domaine {domain}",
|
||||
"account.block_short": "Bloquer",
|
||||
@ -73,7 +80,7 @@
|
||||
"account.locked_info": "Ce compte est privé. Son ou sa propriétaire approuve manuellement qui peut le suivre.",
|
||||
"account.media": "Médias",
|
||||
"account.mention": "Mentionner @{name}",
|
||||
"account.moved_to": "{name} a indiqué que son nouveau compte est maintenant :",
|
||||
"account.moved_to": "{name} a indiqué que son nouveau compte est maintenant :",
|
||||
"account.mute": "Masquer @{name}",
|
||||
"account.mute_notifications_short": "Désactiver les notifications",
|
||||
"account.mute_short": "Mettre en sourdine",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Actividade",
|
||||
"account.add_note": "Engadir nota persoal",
|
||||
"account.add_or_remove_from_list": "Engadir ou eliminar das listaxes",
|
||||
"account.badges.admin": "Admin",
|
||||
"account.badges.blocked": "Bloqueada",
|
||||
"account.badges.bot": "Automatizada",
|
||||
"account.badges.domain_blocked": "Dominio bloqueado",
|
||||
"account.badges.follows_you": "Séguete",
|
||||
"account.badges.group": "Grupo",
|
||||
"account.badges.muted": "Silenciada",
|
||||
"account.badges.mutuals": "Seguimento mútuo",
|
||||
"account.badges.requested_to_follow": "Solicitou seguirte",
|
||||
"account.block": "Bloquear @{name}",
|
||||
"account.block_domain": "Bloquear o dominio {domain}",
|
||||
"account.block_short": "Bloquear",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "פעילות",
|
||||
"account.add_note": "הוספת הערה פרטית",
|
||||
"account.add_or_remove_from_list": "הוספה או הסרה מרשימות",
|
||||
"account.badges.admin": "מנהל",
|
||||
"account.badges.blocked": "חסום",
|
||||
"account.badges.bot": "בוט",
|
||||
"account.badges.domain_blocked": "דומיין חסום",
|
||||
"account.badges.follows_you": "במעקב אחריך",
|
||||
"account.badges.group": "קבוצה",
|
||||
"account.badges.muted": "מושתק",
|
||||
"account.badges.mutuals": "אתם עוקביםות הדדית",
|
||||
"account.badges.requested_to_follow": "ביקשו לעקוב אחריך",
|
||||
"account.block": "חסמי את @{name}",
|
||||
"account.block_domain": "חסמו את קהילת {domain}",
|
||||
"account.block_short": "לחסום",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Virkni",
|
||||
"account.add_note": "Bæta við einkaminnispunkti",
|
||||
"account.add_or_remove_from_list": "Bæta við eða fjarlægja af listum",
|
||||
"account.badges.admin": "Stjóri",
|
||||
"account.badges.blocked": "Lokað á",
|
||||
"account.badges.bot": "Yrki",
|
||||
"account.badges.domain_blocked": "Útilokað lén",
|
||||
"account.badges.follows_you": "Fylgist með þér",
|
||||
"account.badges.group": "Hópur",
|
||||
"account.badges.muted": "Þaggað",
|
||||
"account.badges.mutuals": "Þið fylgist með hvor öðrum",
|
||||
"account.badges.requested_to_follow": "Bað um að fylgjast með þér",
|
||||
"account.block": "Loka á @{name}",
|
||||
"account.block_domain": "Útiloka lénið {domain}",
|
||||
"account.block_short": "Útiloka",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Attività",
|
||||
"account.add_note": "Aggiungi una nota personale",
|
||||
"account.add_or_remove_from_list": "Aggiungi o Rimuovi dalle liste",
|
||||
"account.badges.admin": "Amministratore/trice",
|
||||
"account.badges.blocked": "Bloccato",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Dominio bloccato",
|
||||
"account.badges.follows_you": "Ti segue",
|
||||
"account.badges.group": "Gruppo",
|
||||
"account.badges.muted": "Silenziato",
|
||||
"account.badges.mutuals": "Vi seguite a vicenda",
|
||||
"account.badges.requested_to_follow": "Ha richiesto di seguirti",
|
||||
"account.block": "Blocca @{name}",
|
||||
"account.block_domain": "Blocca dominio {domain}",
|
||||
"account.block_short": "Blocca",
|
||||
|
||||
@ -14,9 +14,18 @@
|
||||
"about.powered_by": "Decentralizuota socialinė medija, veikianti pagal „{mastodon}“",
|
||||
"about.rules": "Serverio taisyklės",
|
||||
"account.account_note_header": "Asmeninė pastaba",
|
||||
"account.activity": "Veikla",
|
||||
"account.add_note": "Pridėti asmeninę pastabą",
|
||||
"account.add_or_remove_from_list": "Įtraukti arba šalinti iš sąrašų",
|
||||
"account.badges.admin": "Administratorius",
|
||||
"account.badges.blocked": "Užblokuota",
|
||||
"account.badges.bot": "Automatizuotas",
|
||||
"account.badges.domain_blocked": "Užblokuoti serveriai",
|
||||
"account.badges.follows_you": "Seka jus",
|
||||
"account.badges.group": "Grupė",
|
||||
"account.badges.muted": "Nutildytas",
|
||||
"account.badges.mutuals": "Sekate vienas kitą",
|
||||
"account.badges.requested_to_follow": "Prašymai sekti jus",
|
||||
"account.block": "Blokuoti @{name}",
|
||||
"account.block_domain": "Blokuoti serverį {domain}",
|
||||
"account.block_short": "Blokuoti",
|
||||
@ -27,6 +36,7 @@
|
||||
"account.direct": "Privačiai paminėti @{name}",
|
||||
"account.disable_notifications": "Nustoti man pranešti, kai @{name} paskelbia",
|
||||
"account.domain_blocking": "Blokuoti domeną",
|
||||
"account.edit_note": "Redaguoti asmeninę pastabą",
|
||||
"account.edit_profile": "Redaguoti profilį",
|
||||
"account.edit_profile_short": "Redaguoti",
|
||||
"account.enable_notifications": "Pranešti man, kai @{name} paskelbia",
|
||||
@ -39,6 +49,11 @@
|
||||
"account.featured.hashtags": "Grotažymės",
|
||||
"account.featured_tags.last_status_at": "Paskutinis įrašas {date}",
|
||||
"account.featured_tags.last_status_never": "Nėra įrašų",
|
||||
"account.filters.all": "Visa veikla",
|
||||
"account.filters.boosts_toggle": "Rodyti pasidalinimus",
|
||||
"account.filters.posts_boosts": "Įrašai ir pasidalinimai",
|
||||
"account.filters.posts_only": "Įrašai",
|
||||
"account.filters.replies_toggle": "rodyti atsakymus",
|
||||
"account.follow": "Sekti",
|
||||
"account.follow_back": "Sekti atgal",
|
||||
"account.follow_back_short": "Sekti atgal",
|
||||
@ -57,6 +72,7 @@
|
||||
"account.go_to_profile": "Eiti į profilį",
|
||||
"account.hide_reblogs": "Slėpti pasidalinimus iš @{name}",
|
||||
"account.in_memoriam": "Atminimui.",
|
||||
"account.joined_long": "Prisijungė {date}",
|
||||
"account.joined_short": "Prisijungė",
|
||||
"account.languages": "Keisti prenumeruojamas kalbas",
|
||||
"account.link_verified_on": "Šios nuorodos nuosavybė buvo patikrinta {date}",
|
||||
@ -71,6 +87,14 @@
|
||||
"account.muting": "Užtildymas",
|
||||
"account.mutual": "Sekate vienas kitą",
|
||||
"account.no_bio": "Nėra pateikto aprašymo.",
|
||||
"account.node_modal.callout": "Asmeninės pastabos matomos tik jums.",
|
||||
"account.node_modal.edit_title": "Redaguoti asmeninę pastabą",
|
||||
"account.node_modal.error_unknown": "Nepavyko išsaugoti pastabos",
|
||||
"account.node_modal.field_label": "Asmeninė pastaba",
|
||||
"account.node_modal.save": "Išsaugoti",
|
||||
"account.node_modal.title": "Pridėti asmeninę pastabą",
|
||||
"account.note.edit_button": "Redaguoti",
|
||||
"account.note.title": "Asmeninės pastabos (matomos tik jums)",
|
||||
"account.open_original_page": "Atidaryti originalų puslapį",
|
||||
"account.posts": "Įrašai",
|
||||
"account.posts_with_replies": "Įrašai ir atsakymai",
|
||||
@ -90,6 +114,8 @@
|
||||
"account.unmute": "Atšaukti nutildymą @{name}",
|
||||
"account.unmute_notifications_short": "Atšaukti pranešimų nutildymą",
|
||||
"account.unmute_short": "Atšaukti nutildymą",
|
||||
"account_fields_modal.close": "Uždaryti",
|
||||
"account_fields_modal.title": "{name} informacija",
|
||||
"account_note.placeholder": "Spustelėk, kad pridėtum pastabą.",
|
||||
"admin.dashboard.daily_retention": "Naudotojų pasilikimo rodiklis pagal dieną po registracijos",
|
||||
"admin.dashboard.monthly_retention": "Naudotojų pasilikimo rodiklis pagal mėnesį po registracijos",
|
||||
@ -114,9 +140,20 @@
|
||||
"alt_text_modal.done": "Atlikta",
|
||||
"announcement.announcement": "Skelbimas",
|
||||
"annual_report.announcement.action_build": "Sukurti mano Wrapstodon",
|
||||
"annual_report.announcement.action_dismiss": "Ne, ačiū",
|
||||
"annual_report.announcement.action_view": "Peržiūrėti mano Wrapstodon",
|
||||
"annual_report.announcement.description": "Sužinokite daugiau apie savo aktyvumą Mastodon\"e per pastaruosius metus.",
|
||||
"annual_report.announcement.title": "Wrapstodon {year} jau čia",
|
||||
"annual_report.nav_item.badge": "Nauja",
|
||||
"annual_report.shared_page.donate": "Paremti",
|
||||
"annual_report.shared_page.footer": "Sukurta su {heart} Mastodon komandos",
|
||||
"annual_report.shared_page.footer_server_info": "{username} naudoja {domain}, vieną iš daugelio bendruomenių, kurios veikia „Mastodon“ dėka.",
|
||||
"annual_report.summary.archetype.booster.desc_public": "{name} tęsė ieškojimus, kokiais įrašais pasidalinti, kad padėtų kitiems kūrėjams pasiekti puikių rezultatų.",
|
||||
"annual_report.summary.archetype.booster.desc_self": "Tu nuolat ieškojai įrašų, kurais galėtum pasidalinti, tuo puikiai padėdamas kitiems kūrėjams.",
|
||||
"annual_report.summary.archetype.booster.name": "Lankininkas",
|
||||
"annual_report.summary.archetype.die_drei_fragezeichen": "???",
|
||||
"annual_report.summary.close": "Užverti",
|
||||
"annual_report.summary.highlighted_post.title": "Populiariausi įrašai",
|
||||
"annual_report.summary.most_used_app.most_used_app": "labiausiai naudota programa",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "labiausiai naudota grotažymė",
|
||||
"annual_report.summary.new_posts.new_posts": "nauji įrašai",
|
||||
@ -243,11 +280,11 @@
|
||||
"confirmations.private_quote_notify.cancel": "Grįžti prie redagavimo",
|
||||
"confirmations.private_quote_notify.confirm": "Paskelbti įrašą",
|
||||
"confirmations.private_quote_notify.do_not_show_again": "Neberodyti šio pranešimo dar kartą",
|
||||
"confirmations.private_quote_notify.message": "Asmuo, kurį paminite, ir kiti paminėti asmenys bus informuoti ir galės peržiūrėti jūsų įrašą, net jei jie neseka jūsų.",
|
||||
"confirmations.private_quote_notify.message": "Asmuo, kurį komentuojate, ir kiti paminėti asmenys bus informuoti ir galės peržiūrėti jūsų įrašą, net jei jie neseka jūsų.",
|
||||
"confirmations.private_quote_notify.title": "Bendrinti su sekėjais ir paminėtais (su @) naudotojais?",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "Daugiau man nepriminti",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Supratau",
|
||||
"confirmations.quiet_post_quote_info.message": "Kai norite paminėti tylų viešą įrašą, jūsų įrašas bus paslėptas Tendencijų sąrašuose.",
|
||||
"confirmations.quiet_post_quote_info.message": "Kai norite komentuoti užtildytą viešą įrašą, jūsų įrašas bus paslėptas „Populiaru“ sąrašuose.",
|
||||
"confirmations.quiet_post_quote_info.title": "Kai paminite tylius viešus įrašus",
|
||||
"confirmations.redraft.confirm": "Ištrinti ir iš naujo parengti",
|
||||
"confirmations.redraft.message": "Ar tikrai nori ištrinti šį įrašą ir parengti jį iš naujo? Bus prarasti mėgstami ir pasidalinimai, o atsakymai į originalų įrašą bus panaikinti.",
|
||||
@ -442,7 +479,7 @@
|
||||
"hints.profiles.see_more_followers": "Žiūrėti daugiau sekėjų serveryje {domain}",
|
||||
"hints.profiles.see_more_follows": "Žiūrėti daugiau sekimų serveryje {domain}",
|
||||
"hints.profiles.see_more_posts": "Žiūrėti daugiau įrašų serveryje {domain}",
|
||||
"home.column_settings.show_quotes": "Rodyti paminėjimus",
|
||||
"home.column_settings.show_quotes": "Rodyti komentarus",
|
||||
"home.column_settings.show_reblogs": "Rodyti pakėlimus",
|
||||
"home.column_settings.show_replies": "Rodyti atsakymus",
|
||||
"home.hide_announcements": "Slėpti skelbimus",
|
||||
@ -497,7 +534,7 @@
|
||||
"keyboard_shortcuts.open_media": "Atidaryti mediją",
|
||||
"keyboard_shortcuts.pinned": "Atverti prisegtų įrašų sąrašą",
|
||||
"keyboard_shortcuts.profile": "Atidaryti autoriaus (-ės) profilį",
|
||||
"keyboard_shortcuts.quote": "Paminėti įrašą",
|
||||
"keyboard_shortcuts.quote": "Komentuoti įrašą",
|
||||
"keyboard_shortcuts.reply": "Atsakyti į įrašą",
|
||||
"keyboard_shortcuts.requests": "Atidaryti sekimo prašymų sąrašą",
|
||||
"keyboard_shortcuts.search": "Fokusuoti paieškos juostą",
|
||||
@ -612,7 +649,7 @@
|
||||
"notification.label.mention": "Paminėjimas",
|
||||
"notification.label.private_mention": "Privatus paminėjimas",
|
||||
"notification.label.private_reply": "Privatus atsakymas",
|
||||
"notification.label.quote": "{name} paminėjo jūsų įrašą",
|
||||
"notification.label.quote": "{name} pakomentavo jūsų įrašą",
|
||||
"notification.label.reply": "Atsakymas",
|
||||
"notification.mention": "Paminėjimas",
|
||||
"notification.mentioned_you": "{name} paminėjo jus",
|
||||
@ -627,7 +664,7 @@
|
||||
"notification.moderation_warning.action_suspend": "Tavo paskyra buvo sustabdyta.",
|
||||
"notification.own_poll": "Tavo apklausa baigėsi",
|
||||
"notification.poll": "Baigėsi apklausa, kurioje balsavai",
|
||||
"notification.quoted_update": "{name} redagavo jūsų cituotą įrašą",
|
||||
"notification.quoted_update": "{name} redagavo jūsų pakomentuotą įrašą",
|
||||
"notification.reblog": "{name} dalinosi tavo įrašu",
|
||||
"notification.reblog.name_and_others_with_link": "{name} ir <a>{count, plural,one {dar kažkas} few {# kiti} other {# kitų}}</a> pasidalino tavo įrašu",
|
||||
"notification.relationships_severance_event": "Prarasti sąryšiai su {name}",
|
||||
@ -671,7 +708,7 @@
|
||||
"notifications.column_settings.mention": "Paminėjimai:",
|
||||
"notifications.column_settings.poll": "Balsavimo rezultatai:",
|
||||
"notifications.column_settings.push": "Tiesioginiai pranešimai",
|
||||
"notifications.column_settings.quote": "Paminėjimai:",
|
||||
"notifications.column_settings.quote": "Komentarai:",
|
||||
"notifications.column_settings.reblog": "Pasidalinimai:",
|
||||
"notifications.column_settings.show": "Rodyti stulpelyje",
|
||||
"notifications.column_settings.sound": "Paleisti garsą",
|
||||
@ -747,18 +784,19 @@
|
||||
"privacy.private.short": "Sekėjai",
|
||||
"privacy.public.long": "Bet kas iš Mastodon ir ne Mastodon",
|
||||
"privacy.public.short": "Vieša",
|
||||
"privacy.quote.disabled": "{visibility}, paminėjimai išjungti",
|
||||
"privacy.quote.limited": "{visibility}, paminėjimai apriboti",
|
||||
"privacy.quote.anyone": "{visibility}, komentarai galimi",
|
||||
"privacy.quote.disabled": "{visibility}, komentavimas išjungtas",
|
||||
"privacy.quote.limited": "{visibility}, komentavimas apribotas",
|
||||
"privacy.unlisted.additional": "Tai veikia lygiai taip pat, kaip ir vieša, tik įrašas nebus rodomas tiesioginiuose srautuose, grotažymėse, naršyme ar Mastodon paieškoje, net jei esi įtraukęs (-usi) visą paskyrą.",
|
||||
"privacy.unlisted.long": "Paslėptas nuo „Mastodon“ paieškos rezultatų, tendencijų ir viešų įrašų sienų",
|
||||
"privacy.unlisted.short": "Tyliai vieša",
|
||||
"privacy_policy.last_updated": "Paskutinį kartą atnaujinta {date}",
|
||||
"privacy_policy.title": "Privatumo politika",
|
||||
"quote_error.edit": "Paminėjimai negali būti pridedami, kai keičiamas įrašas.",
|
||||
"quote_error.poll": "Cituoti apklausose negalima.",
|
||||
"quote_error.private_mentions": "Cituoti privačius paminėjus nėra leidžiama.",
|
||||
"quote_error.quote": "Leidžiama pateikti tik vieną citatą vienu metu.",
|
||||
"quote_error.unauthorized": "Jums neleidžiama cituoti šio įrašo.",
|
||||
"quote_error.edit": "Komentuoti negalima, kai keičiamas įrašas.",
|
||||
"quote_error.poll": "Komentuoti apklausose negalima.",
|
||||
"quote_error.private_mentions": "Komentuoti privačius paminėjimus nėra leidžiama.",
|
||||
"quote_error.quote": "Leidžiama pateikti tik vieną komentarą vienu metu.",
|
||||
"quote_error.unauthorized": "Jums neleidžiama komentuoti šio įrašo.",
|
||||
"quote_error.upload": "Cituoti ir pridėti papildomas bylas negalima.",
|
||||
"recommended": "Rekomenduojama",
|
||||
"refresh": "Atnaujinti",
|
||||
@ -777,7 +815,7 @@
|
||||
"relative_time.today": "šiandien",
|
||||
"remove_quote_hint.button_label": "Supratau",
|
||||
"remove_quote_hint.message": "Tai galite padaryti iš {icon} parinkčių meniu.",
|
||||
"remove_quote_hint.title": "Norite pašalinti savo citatą?",
|
||||
"remove_quote_hint.title": "Norite pašalinti savo komentarą?",
|
||||
"reply_indicator.attachments": "{count, plural, one {# priedas} few {# priedai} many {# priedo} other {# priedų}}",
|
||||
"reply_indicator.cancel": "Atšaukti",
|
||||
"reply_indicator.poll": "Apklausa",
|
||||
@ -869,13 +907,13 @@
|
||||
"status.admin_account": "Atidaryti prižiūrėjimo sąsają @{name}",
|
||||
"status.admin_domain": "Atidaryti prižiūrėjimo sąsają {domain}",
|
||||
"status.admin_status": "Atidaryti šį įrašą prižiūrėjimo sąsajoje",
|
||||
"status.all_disabled": "Įrašo dalinimaisi ir paminėjimai išjungti",
|
||||
"status.all_disabled": "Įrašo pasidalijimai ir komentavimai išjungti",
|
||||
"status.block": "Blokuoti @{name}",
|
||||
"status.bookmark": "Žymė",
|
||||
"status.cancel_reblog_private": "Nesidalinti",
|
||||
"status.cannot_quote": "Jums neleidžiama paminėti šio įrašo",
|
||||
"status.cannot_quote": "Jums neleidžiama komentuoti šio įrašo",
|
||||
"status.cannot_reblog": "Šis įrašas negali būti pakeltas.",
|
||||
"status.contains_quote": "Turi citatą",
|
||||
"status.contains_quote": "Turi komentarą",
|
||||
"status.context.loading": "Įkeliama daugiau atsakymų",
|
||||
"status.context.loading_error": "Nepavyko įkelti naujų atsakymų",
|
||||
"status.context.loading_success": "Įkelti nauji atsakymai",
|
||||
@ -908,8 +946,8 @@
|
||||
"status.mute_conversation": "Nutildyti pokalbį",
|
||||
"status.open": "Išskleisti šį įrašą",
|
||||
"status.pin": "Prisegti prie profilio",
|
||||
"status.quote": "Paminėjimai",
|
||||
"status.quote.cancel": "Atšaukti paminėjimą",
|
||||
"status.quote": "Komentuoti",
|
||||
"status.quote.cancel": "Atšaukti komentarą",
|
||||
"status.quote_error.blocked_account_hint.title": "Šis įrašas yra paslėptas, nes jūs esate užblokavę @{name}.",
|
||||
"status.quote_error.blocked_domain_hint.title": "Šis įrašas yra paslėptas, nes jūs užblokavote {domain}.",
|
||||
"status.quote_error.filtered": "Paslėpta dėl vieno iš jūsų filtrų",
|
||||
@ -925,14 +963,14 @@
|
||||
"status.quote_noun": "Paminėjimas",
|
||||
"status.quote_policy_change": "Keisti, kas gali cituoti",
|
||||
"status.quote_post_author": "Paminėjo įrašą iš @{name}",
|
||||
"status.quote_private": "Privačių įrašų negalima cituoti",
|
||||
"status.quotes.empty": "Šio įrašo dar niekas nepaminėjo. Kai kas nors tai padarys, jie bus rodomi čia.",
|
||||
"status.quotes.local_other_disclaimer": "Autoriaus atmesti įrašo paminėjimai nebus rodomi.",
|
||||
"status.quotes.remote_other_disclaimer": "Čia bus rodoma tik paminėjimai iš {domain}. Autoriaus atmesti įrašo paminėjimai nebus rodomi.",
|
||||
"status.quotes_count": "{count, plural, one {{counter} paminėjimas} few {{counter} paminėjimai} many {{counter} paminėjimai} other {{counter} paminėjimai}}",
|
||||
"status.quote_private": "Privačių įrašų negalima komentuoti",
|
||||
"status.quotes.empty": "Šio įrašo dar niekas nepakomentavo. Kai kas nors tai padarys, jie bus rodomi čia.",
|
||||
"status.quotes.local_other_disclaimer": "Autoriaus atmesti įrašo komentavimai nebus rodomi.",
|
||||
"status.quotes.remote_other_disclaimer": "Čia bus rodoma tik komentavimai iš {domain}. Autoriaus atmesti įrašo komentavimai nebus rodomi.",
|
||||
"status.quotes_count": "{count, plural, one {{counter} komentaras} few {{counter} komentarai} many {{counter} komentarų} other {{counter} komentarai}}",
|
||||
"status.read_more": "Skaityti daugiau",
|
||||
"status.reblog": "Dalintis",
|
||||
"status.reblog_or_quote": "Dalintis arba cituoti",
|
||||
"status.reblog_or_quote": "Dalintis arba komentuoti",
|
||||
"status.reblog_private": "Vėl pasidalinkite su savo sekėjais",
|
||||
"status.reblogged_by": "{name} pasidalino",
|
||||
"status.reblogs.empty": "Šiuo įrašu dar niekas nesidalino. Kai kas nors tai padarys, jie bus rodomi čia.",
|
||||
@ -946,8 +984,8 @@
|
||||
"status.reply": "Atsakyti",
|
||||
"status.replyAll": "Atsakyti į giją",
|
||||
"status.report": "Pranešti apie @{name}",
|
||||
"status.request_quote": "Citavimo sutikimas",
|
||||
"status.revoke_quote": "Pašalinti mano įrašo citavimą iš @{name}’s įrašo",
|
||||
"status.request_quote": "Prašymas pakomentuoti",
|
||||
"status.revoke_quote": "Pašalinti mano įrašą iš @{name}’s įrašo",
|
||||
"status.sensitive_warning": "Jautrus turinys",
|
||||
"status.share": "Bendrinti",
|
||||
"status.show_less_all": "Rodyti mažiau visiems",
|
||||
@ -985,7 +1023,7 @@
|
||||
"upload_button.label": "Pridėti vaizdų, vaizdo įrašą arba garso failą",
|
||||
"upload_error.limit": "Viršyta failo įkėlimo riba.",
|
||||
"upload_error.poll": "Failų įkėlimas neleidžiamas su apklausomis.",
|
||||
"upload_error.quote": "Paminint įrašą bylos įkėlimas negalimas.",
|
||||
"upload_error.quote": "Komentuojant įrašą failo įkėlimas negalimas.",
|
||||
"upload_form.drag_and_drop.instructions": "Kad paimtum medijos priedą, paspausk tarpo arba įvedimo klavišą. Tempant naudok rodyklių klavišus, kad perkeltum medijos priedą bet kuria kryptimi. Dar kartą paspausk tarpo arba įvedimo klavišą, kad nuleistum medijos priedą naujoje vietoje, arba paspausk grįžimo klavišą, kad atšauktum.",
|
||||
"upload_form.drag_and_drop.on_drag_cancel": "Nutempimas buvo atšauktas. Medijos priedas {item} buvo nuleistas.",
|
||||
"upload_form.drag_and_drop.on_drag_end": "Medijos priedas {item} buvo nuleistas.",
|
||||
@ -1010,18 +1048,18 @@
|
||||
"video.volume_down": "Patildyti",
|
||||
"video.volume_up": "Pagarsinti",
|
||||
"visibility_modal.button_title": "Nustatyti matomumą",
|
||||
"visibility_modal.direct_quote_warning.text": "Jei išsaugosite dabartinius nustatymus, įterpta citata bus konvertuota į nuorodą.",
|
||||
"visibility_modal.direct_quote_warning.title": "Cituojami įrašai negali būti įterpiami į privačius paminėjimus",
|
||||
"visibility_modal.direct_quote_warning.text": "Jei išsaugosite dabartinius nustatymus, įterptas (embeded) komentaras bus konvertuotas į nuorodą.",
|
||||
"visibility_modal.direct_quote_warning.title": "Komentarai negali būti įterpiami į privačius paminėjimus",
|
||||
"visibility_modal.header": "Matomumas ir sąveika",
|
||||
"visibility_modal.helper.direct_quoting": "Privatūs paminėjimai su žyma @, parašyti platformoje „Mastodon“, negali būti cituojami kitų.",
|
||||
"visibility_modal.helper.direct_quoting": "Privatūs paminėjimai su žyma @, parašyti platformoje „Mastodon“, negali būti komentuojami kitų.",
|
||||
"visibility_modal.helper.privacy_editing": "Matomumo nustatymai negali būti keičiami po to, kai įrašas yra paskelbtas.",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "Privačių įrašų paminėjimai negali būti skelbiami viešai.",
|
||||
"visibility_modal.helper.private_quoting": "Tik sekėjams skirti įrašai, parašyti platformoje „Mastodon“, negali būti cituojami kitų.",
|
||||
"visibility_modal.helper.unlisted_quoting": "Kai žmonės jus cituos, jų įrašai taip pat bus paslėpti iš populiariausių naujienų srauto.",
|
||||
"visibility_modal.helper.privacy_private_self_quote": "Prie privataus įrašo tavo pridėti komentarai negali būti skelbiami viešai.",
|
||||
"visibility_modal.helper.private_quoting": "Tik sekėjams skirti įrašai, parašyti platformoje „Mastodon“, negali būti komentuojami kitų.",
|
||||
"visibility_modal.helper.unlisted_quoting": "Kai žmonės jus komentuoja, jų įrašai taip pat bus paslėpti iš populiariausių naujienų srauto.",
|
||||
"visibility_modal.instructions": "Kontroliuokite, kas gali bendrauti su šiuo įrašu. Taip pat galite taikyti nustatymus visiems būsimiems įrašams, pereidami į <link>Preferences > Posting defaults</link>.",
|
||||
"visibility_modal.privacy_label": "Matomumas",
|
||||
"visibility_modal.quote_followers": "Tik sekėjai",
|
||||
"visibility_modal.quote_label": "Kas gali cituoti",
|
||||
"visibility_modal.quote_label": "Kas gali komentuoti",
|
||||
"visibility_modal.quote_nobody": "Tik aš",
|
||||
"visibility_modal.quote_public": "Visi",
|
||||
"visibility_modal.save": "Išsaugoti"
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Veprimtari",
|
||||
"account.add_note": "Shtoni një shënim personal",
|
||||
"account.add_or_remove_from_list": "Shtoni ose Hiqni prej listash",
|
||||
"account.badges.admin": "Përgjegjës",
|
||||
"account.badges.blocked": "E bllokuar",
|
||||
"account.badges.bot": "Robot",
|
||||
"account.badges.domain_blocked": "Përkatësi e bllokuar",
|
||||
"account.badges.follows_you": "Ju ndjek",
|
||||
"account.badges.group": "Grup",
|
||||
"account.badges.muted": "E heshtuar",
|
||||
"account.badges.mutuals": "Ndiqni njëri-tjetrin",
|
||||
"account.badges.requested_to_follow": "Kërkoi t’ju ndjekë",
|
||||
"account.block": "Blloko @{name}",
|
||||
"account.block_domain": "Blloko përkatësinë {domain}",
|
||||
"account.block_short": "Bllokoje",
|
||||
@ -219,7 +226,7 @@
|
||||
"collections.description_length_hint": "Kufi prej 100 shenjash",
|
||||
"collections.error_loading_collections": "Pati një gabim teksa provohej të ngarkoheshin koleksionet tuaj.",
|
||||
"collections.mark_as_sensitive": "Vëri shenjë si rezervat",
|
||||
"collections.mark_as_sensitive_hint": "Bën fshehejn e përshkrimit të koleksionit dhe llogarive prapa një sinjalizimi lënde. Emri i koleksionit do të jetë ende i dukshëm.",
|
||||
"collections.mark_as_sensitive_hint": "Bën fshehjen e përshkrimit të koleksionit dhe llogarive prapa një sinjalizimi lënde. Emri i koleksionit do të jetë ende i dukshëm.",
|
||||
"collections.name_length_hint": "Kufi prej 100 shenjash",
|
||||
"collections.no_collections_yet": "Ende pa koleksione.",
|
||||
"collections.topic_hint": "Shtoni një hashtag që ndihmon të tjerët të kuptojnë temën kryesore të këtij koleksion.",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Aktivite",
|
||||
"account.add_note": "Kişisel bir not ekle",
|
||||
"account.add_or_remove_from_list": "Listelere ekle veya kaldır",
|
||||
"account.badges.admin": "Yönetici",
|
||||
"account.badges.blocked": "Engellendi",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Engellenen alan adı",
|
||||
"account.badges.follows_you": "Seni takip ediyor",
|
||||
"account.badges.group": "Grup",
|
||||
"account.badges.muted": "Sessize Alındı",
|
||||
"account.badges.mutuals": "Birbirinizi takip ediyorsunuz",
|
||||
"account.badges.requested_to_follow": "Size takip isteği gönderdi",
|
||||
"account.block": "@{name} adlı kişiyi engelle",
|
||||
"account.block_domain": "{domain} alan adını engelle",
|
||||
"account.block_short": "Engelle",
|
||||
@ -212,21 +219,31 @@
|
||||
"closed_registrations_modal.find_another_server": "Başka sunucu bul",
|
||||
"closed_registrations_modal.preamble": "Mastodon merkeziyetsizdir, bu yüzden hesabınızı nerede oluşturursanız oluşturun, bu sunucudaki herhangi birini takip edebilecek veya onunla etkileşebileceksiniz. Hatta kendi sunucunuzu bile barındırabilirsiniz!",
|
||||
"closed_registrations_modal.title": "Mastodon'a kayıt olmak",
|
||||
"collections.collection_description": "Açıklama",
|
||||
"collections.collection_name": "Ad",
|
||||
"collections.collection_topic": "Konu",
|
||||
"collections.create_a_collection_hint": "En sevdiğiniz hesapları başkalarına önermek veya paylaşmak için bir koleksiyon oluşturun.",
|
||||
"collections.create_collection": "Koleksiyon oluştur",
|
||||
"collections.delete_collection": "Koleksiyonu sil",
|
||||
"collections.description_length_hint": "100 karakterle sınırlı",
|
||||
"collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.",
|
||||
"collections.mark_as_sensitive": "Hassas olarak işaretle",
|
||||
"collections.mark_as_sensitive_hint": "Koleksiyonun açıklamasını ve hesaplarını içerik uyarısının arkasında gizler. Koleksiyon adı hala görünür olacaktır.",
|
||||
"collections.name_length_hint": "100 karakterle sınırlı",
|
||||
"collections.no_collections_yet": "Henüz hiçbir koleksiyon yok.",
|
||||
"collections.topic_hint": "Bu koleksiyonun ana konusunu başkalarının anlamasına yardımcı olacak bir etiket ekleyin.",
|
||||
"collections.view_collection": "Koleksiyonu görüntüle",
|
||||
"column.about": "Hakkında",
|
||||
"column.blocks": "Engellenen kullanıcılar",
|
||||
"column.bookmarks": "Yer İşaretleri",
|
||||
"column.collections": "Koleksiyonlarım",
|
||||
"column.community": "Yerel ağ akışı",
|
||||
"column.create_collection": "Koleksiyon oluştur",
|
||||
"column.create_list": "Liste oluştur",
|
||||
"column.direct": "Özel bahsetmeler",
|
||||
"column.directory": "Profillere göz at",
|
||||
"column.domain_blocks": "Engellenen alan adları",
|
||||
"column.edit_collection": "Koleksiyonu düzenle",
|
||||
"column.edit_list": "Listeyi düzenle",
|
||||
"column.favourites": "Gözdelerin",
|
||||
"column.firehose": "Anlık Akışlar",
|
||||
@ -281,6 +298,9 @@
|
||||
"confirmations.delete.confirm": "Sil",
|
||||
"confirmations.delete.message": "Bu tootu silmek istediğinden emin misin?",
|
||||
"confirmations.delete.title": "Gönderiyi sil?",
|
||||
"confirmations.delete_collection.confirm": "Sil",
|
||||
"confirmations.delete_collection.message": "Bu işlem geri alınamaz.",
|
||||
"confirmations.delete_collection.title": "\"{name}\" silinsin mi?",
|
||||
"confirmations.delete_list.confirm": "Sil",
|
||||
"confirmations.delete_list.message": "Bu listeyi kalıcı olarak silmek istediğinden emin misin?",
|
||||
"confirmations.delete_list.title": "Listeyi sil?",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "Hoạt động",
|
||||
"account.add_note": "Thêm ghi chú",
|
||||
"account.add_or_remove_from_list": "Sửa danh sách",
|
||||
"account.badges.admin": "Quản trị viên",
|
||||
"account.badges.blocked": "Đã chặn",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.badges.domain_blocked": "Máy chủ đã chặn",
|
||||
"account.badges.follows_you": "Theo dõi bạn",
|
||||
"account.badges.group": "Nhóm",
|
||||
"account.badges.muted": "Đã phớt lờ",
|
||||
"account.badges.mutuals": "Theo dõi nhau",
|
||||
"account.badges.requested_to_follow": "Yêu cầu theo dõi bạn",
|
||||
"account.block": "Chặn @{name}",
|
||||
"account.block_domain": "Chặn mọi thứ từ {domain}",
|
||||
"account.block_short": "Chặn",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "活动",
|
||||
"account.add_note": "添加个人备注",
|
||||
"account.add_or_remove_from_list": "从列表中添加或移除",
|
||||
"account.badges.admin": "管理员",
|
||||
"account.badges.blocked": "已屏蔽",
|
||||
"account.badges.bot": "机器人",
|
||||
"account.badges.domain_blocked": "已屏蔽域名",
|
||||
"account.badges.follows_you": "关注了你",
|
||||
"account.badges.group": "群组",
|
||||
"account.badges.muted": "已隐藏",
|
||||
"account.badges.mutuals": "你们互相关注",
|
||||
"account.badges.requested_to_follow": "向您发送了关注请求",
|
||||
"account.block": "屏蔽 @{name}",
|
||||
"account.block_domain": "屏蔽 {domain} 实例",
|
||||
"account.block_short": "屏蔽",
|
||||
|
||||
@ -17,8 +17,15 @@
|
||||
"account.activity": "活動",
|
||||
"account.add_note": "新增個人備註",
|
||||
"account.add_or_remove_from_list": "自列表中新增或移除",
|
||||
"account.badges.admin": "管理員",
|
||||
"account.badges.blocked": "已封鎖",
|
||||
"account.badges.bot": "機器人",
|
||||
"account.badges.domain_blocked": "已封鎖網域",
|
||||
"account.badges.follows_you": "已跟隨您",
|
||||
"account.badges.group": "群組",
|
||||
"account.badges.muted": "已靜音",
|
||||
"account.badges.mutuals": "跟隨彼此",
|
||||
"account.badges.requested_to_follow": "要求跟隨您",
|
||||
"account.block": "封鎖 @{name}",
|
||||
"account.block_domain": "封鎖來自 {domain} 網域之所有內容",
|
||||
"account.block_short": "封鎖",
|
||||
|
||||
@ -6,6 +6,7 @@ interface BaseMenuItem {
|
||||
text: string;
|
||||
description?: string;
|
||||
icon?: IconProp;
|
||||
iconId?: string;
|
||||
highlighted?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
|
||||
11
app/javascript/mastodon/utils/checks.ts
Normal file
11
app/javascript/mastodon/utils/checks.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function isValidUrl(
|
||||
url: string,
|
||||
allowedProtocols = ['https:'],
|
||||
): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return allowedProtocols.includes(parsedUrl.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1
app/javascript/material-icons/400-24px/link_2-fill.svg
Normal file
1
app/javascript/material-icons/400-24px/link_2-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M318-120q-82 0-140-58t-58-140q0-40 15-76t43-64l134-133 56 56-134 134q-17 17-25.5 38.5T200-318q0 49 34.5 83.5T318-200q23 0 45-8.5t39-25.5l133-134 57 57-134 133q-28 28-64 43t-76 15Zm79-220-57-57 223-223 57 57-223 223Zm251-28-56-57 134-133q17-17 25-38t8-44q0-50-34-85t-84-35q-23 0-44.5 8.5T558-726L425-592l-57-56 134-134q28-28 64-43t76-15q82 0 139.5 58T839-641q0 39-14.5 75T782-502L648-368Z"/></svg>
|
||||
|
After Width: | Height: | Size: 493 B |
1
app/javascript/material-icons/400-24px/link_2.svg
Normal file
1
app/javascript/material-icons/400-24px/link_2.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M318-120q-82 0-140-58t-58-140q0-40 15-76t43-64l134-133 56 56-134 134q-17 17-25.5 38.5T200-318q0 49 34.5 83.5T318-200q23 0 45-8.5t39-25.5l133-134 57 57-134 133q-28 28-64 43t-76 15Zm79-220-57-57 223-223 57 57-223 223Zm251-28-56-57 134-133q17-17 25-38t8-44q0-50-34-85t-84-35q-23 0-44.5 8.5T558-726L425-592l-57-56 134-134q28-28 64-43t76-15q82 0 139.5 58T839-641q0 39-14.5 75T782-502L648-368Z"/></svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@ -8248,7 +8248,6 @@ noscript {
|
||||
}
|
||||
|
||||
.account__header {
|
||||
overflow: hidden;
|
||||
container: account-header / inline-size;
|
||||
|
||||
&.inactive {
|
||||
|
||||
@ -994,7 +994,7 @@ de:
|
||||
trendable: Trendfähig
|
||||
unreviewed: Ungeprüft
|
||||
usable: Verwendbar
|
||||
name: Name
|
||||
name: Hashtag
|
||||
newest: Neueste
|
||||
oldest: Älteste
|
||||
open: Öffentlich anzeigen
|
||||
|
||||
@ -59,7 +59,7 @@ services:
|
||||
web:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.5
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec puma -C config/puma.rb
|
||||
@ -83,7 +83,7 @@ services:
|
||||
# build:
|
||||
# dockerfile: ./streaming/Dockerfile
|
||||
# context: .
|
||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.5
|
||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming/index.js
|
||||
@ -102,7 +102,7 @@ services:
|
||||
sidekiq:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.5
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.6
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
||||
@ -17,7 +17,7 @@ module Mastodon
|
||||
end
|
||||
|
||||
def default_prerelease
|
||||
'alpha.3'
|
||||
'alpha.4'
|
||||
end
|
||||
|
||||
def prerelease
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user