Merge commit '2e30044a374811bc94fd62a8159cb2c9ffe18a4d' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-02-06 22:45:55 +01:00
65 changed files with 1545 additions and 146 deletions

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
<path d="M13.128 7.586a.666.666 0 0 0 0-.943l-3.771-3.77a.667.667 0 0 0-.943.942 1.333 1.333 0 0 1 0 1.885L6.641 7.473a2 2 0 0 1-2.046.48v.002l-1.263-.414-.004-.002a.668.668 0 0 0-.684.16l-.358.358 5.657 5.657.358-.357a.67.67 0 0 0 .16-.684l-.001-.004-.415-1.265a2.002 2.002 0 0 1 .482-2.045L10.3 7.586a1.333 1.333 0 0 1 1.885 0 .667.667 0 0 0 .943 0Zm.943.942a2 2 0 0 1-2.829 0L9.47 10.301l-.06.07a.666.666 0 0 0-.124.524l.024.09.001.004.416 1.263a1.999 1.999 0 0 1-.483 2.046l-.358.359a1.335 1.335 0 0 1-1.886 0L4.642 12.3l-1.885 1.885a.667.667 0 1 1-.942-.943L3.7 11.357 1.343 9.001a1.335 1.335 0 0 1 0-1.887l.358-.358a2 2 0 0 1 2.051-.481l1.26.414.003.001a.67.67 0 0 0 .614-.1l.07-.06L7.47 4.757A2 2 0 0 1 10.3 1.93l3.77 3.77a2 2 0 0 1 0 2.83Z"/>
</svg>

After

Width:  |  Height:  |  Size: 833 B

View File

@@ -27,10 +27,12 @@ export const TIMELINE_INSERT = 'TIMELINE_INSERT';
// When adding new special markers here, make sure to update TIMELINE_NON_STATUS_MARKERS in actions/timelines_typed.js
export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions';
export const TIMELINE_GAP = null;
export const TIMELINE_PINNED_VIEW_ALL = 'pinned-view-all';
export const TIMELINE_NON_STATUS_MARKERS = [
TIMELINE_GAP,
TIMELINE_SUGGESTIONS,
TIMELINE_PINNED_VIEW_ALL,
];
export const loadPending = timeline => ({

View File

@@ -8,8 +8,9 @@ export interface ApiRelationshipJSON {
following: boolean;
id: string;
languages: string[] | null;
muting_notifications: boolean;
muting: boolean;
muting_notifications: boolean;
muting_expires_at: string | null;
note: string;
notifying: boolean;
requested_by: boolean;

View File

@@ -8,7 +8,7 @@ const meta = {
component: badges.Badge,
title: 'Components/Badge',
args: {
label: 'Example',
label: undefined,
},
} satisfies Meta<typeof badges.Badge>;
@@ -16,16 +16,22 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Default: Story = {
args: {
label: 'Example',
},
};
export const Domain: Story = {
args: {
...Default.args,
domain: 'example.com',
},
};
export const CustomIcon: Story = {
args: {
...Default.args,
icon: <CelebrationIcon />,
},
};
@@ -57,6 +63,13 @@ export const Muted: Story = {
},
};
export const MutedWithDate: Story = {
render(args) {
const futureDate = new Date(new Date().getFullYear(), 11, 31).toISOString();
return <badges.MutedBadge {...args} expiresAt={futureDate} />;
},
};
export const Blocked: Story = {
render(args) {
return <badges.BlockedBadge {...args} />;

View File

@@ -1,6 +1,6 @@
import type { FC, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
@@ -36,21 +36,25 @@ export const Badge: FC<BadgeProps> = ({
</div>
);
export const AdminBadge: FC<Partial<BadgeProps>> = (props) => (
export const AdminBadge: FC<Partial<BadgeProps>> = ({ label, ...props }) => (
<Badge
icon={<AdminIcon />}
label={
<FormattedMessage id='account.badges.admin' defaultMessage='Admin' />
label ?? (
<FormattedMessage id='account.badges.admin' defaultMessage='Admin' />
)
}
{...props}
/>
);
export const GroupBadge: FC<Partial<BadgeProps>> = (props) => (
export const GroupBadge: FC<Partial<BadgeProps>> = ({ label, ...props }) => (
<Badge
icon={<GroupsIcon />}
label={
<FormattedMessage id='account.badges.group' defaultMessage='Group' />
label ?? (
<FormattedMessage id='account.badges.group' defaultMessage='Group' />
)
}
{...props}
/>
@@ -66,21 +70,54 @@ export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => (
/>
);
export const MutedBadge: FC<Partial<BadgeProps>> = (props) => (
<Badge
icon={<VolumeOffIcon />}
label={
<FormattedMessage id='account.badges.muted' defaultMessage='Muted' />
}
{...props}
/>
);
export const MutedBadge: FC<
Partial<BadgeProps> & { expiresAt?: string | null }
> = ({ expiresAt, label, ...props }) => {
// Format the date, only showing the year if it's different from the current year.
const intl = useIntl();
let formattedDate: string | null = null;
if (expiresAt) {
const expiresDate = new Date(expiresAt);
const isCurrentYear =
expiresDate.getFullYear() === new Date().getFullYear();
formattedDate = intl.formatDate(expiresDate, {
month: 'short',
day: 'numeric',
...(isCurrentYear ? {} : { year: 'numeric' }),
});
}
return (
<Badge
icon={<VolumeOffIcon />}
label={
label ??
(formattedDate ? (
<FormattedMessage
id='account.badges.muted_until'
defaultMessage='Muted until {until}'
values={{
until: formattedDate,
}}
/>
) : (
<FormattedMessage id='account.badges.muted' defaultMessage='Muted' />
))
}
{...props}
/>
);
};
export const BlockedBadge: FC<Partial<BadgeProps>> = (props) => (
export const BlockedBadge: FC<Partial<BadgeProps>> = ({ label, ...props }) => (
<Badge
icon={<BlockIcon />}
label={
<FormattedMessage id='account.badges.blocked' defaultMessage='Blocked' />
label ?? (
<FormattedMessage
id='account.badges.blocked'
defaultMessage='Blocked'
/>
)
}
{...props}
/>

View File

@@ -0,0 +1,23 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
max-width: 600px;
padding: 20px;
gap: 16px;
text-align: center;
color: var(--color-text-primary);
}
.content {
h3 {
font-size: 17px;
font-weight: 500;
}
p {
font-size: 15px;
margin-top: 8px;
color: var(--color-text-secondary);
}
}

View File

@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Button } from '../button';
import { EmptyState } from '.';
const meta = {
title: 'Components/EmptyState',
component: EmptyState,
argTypes: {
title: {
control: 'text',
type: 'string',
table: {
type: { summary: 'string' },
},
},
},
} satisfies Meta<typeof EmptyState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
message: 'Try clearing filters or refreshing the page.',
},
};
export const WithoutMessage: Story = {
args: {
message: undefined,
},
};
export const WithAction: Story = {
args: {
...Default.args,
// eslint-disable-next-line react/jsx-no-bind
children: <Button onClick={() => action('Refresh')}>Refresh</Button>,
},
};

View File

@@ -0,0 +1,32 @@
import { FormattedMessage } from 'react-intl';
import classes from './empty_state.module.scss';
/**
* Simple empty state component with a neutral default title and customisable message.
*
* Action buttons can be passed as `children`
*/
export const EmptyState: React.FC<{
title?: string | React.ReactElement;
message?: string | React.ReactElement;
children?: React.ReactNode;
}> = ({
title = (
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
),
message,
children,
}) => {
return (
<div className={classes.wrapper}>
<div className={classes.content}>
<h3>{title}</h3>
{!!message && <p>{message}</p>}
</div>
{children}
</div>
);
};

View File

@@ -0,0 +1,69 @@
.wrapper {
position: relative;
}
.input {
padding-right: 45px;
}
.menuButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
&::before {
// Subtle divider line separating the button from the input field
content: '';
position: absolute;
inset-inline-start: 0;
inset-block: 10px;
border-inline-start: 1px solid var(--color-border-primary);
}
}
.popover {
z-index: 9999;
box-sizing: border-box;
padding: 4px;
border-radius: 4px;
color: var(--color-text-primary);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-primary);
box-shadow: var(--dropdown-shadow);
// backdrop-filter: $backdrop-blur-filter;
}
.menuItem {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 12px;
font-size: 14px;
line-height: 20px;
border-radius: 4px;
color: var(--color-text-primary);
cursor: pointer;
user-select: none;
&[aria-selected='true'] {
color: var(--color-text-on-brand-base);
background: var(--color-bg-brand-base);
&[aria-disabled='true'] {
color: var(--color-text-on-disabled);
background: var(--color-bg-disabled);
}
}
&[aria-disabled='true'] {
color: var(--color-text-disabled);
cursor: not-allowed;
}
}
.emptyMessage {
padding: 8px 16px;
font-size: 13px;
}

View File

@@ -0,0 +1,92 @@
import { useCallback, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComboboxField } from './combobox_field';
const ComboboxDemo: React.FC = () => {
const [searchValue, setSearchValue] = useState('');
const items = [
{ id: '1', name: 'Apple' },
{ id: '2', name: 'Banana' },
{ id: '3', name: 'Cherry', disabled: true },
{ id: '4', name: 'Date' },
{ id: '5', name: 'Fig', disabled: true },
{ id: '6', name: 'Grape' },
{ id: '7', name: 'Honeydew' },
{ id: '8', name: 'Kiwi' },
{ id: '9', name: 'Lemon' },
{ id: '10', name: 'Mango' },
{ id: '11', name: 'Nectarine' },
{ id: '12', name: 'Orange' },
{ id: '13', name: 'Papaya' },
{ id: '14', name: 'Quince' },
{ id: '15', name: 'Raspberry' },
{ id: '16', name: 'Strawberry' },
{ id: '17', name: 'Tangerine' },
{ id: '19', name: 'Vanilla bean' },
{ id: '20', name: 'Watermelon' },
{ id: '22', name: 'Yellow Passion Fruit' },
{ id: '23', name: 'Zucchini' },
{ id: '24', name: 'Cantaloupe' },
{ id: '25', name: 'Blackberry' },
{ id: '26', name: 'Persimmon' },
{ id: '27', name: 'Lychee' },
{ id: '28', name: 'Dragon Fruit' },
{ id: '29', name: 'Passion Fruit' },
{ id: '30', name: 'Starfruit' },
];
type Fruit = (typeof items)[number];
const getItemId = useCallback((item: Fruit) => item.id, []);
const getIsItemDisabled = useCallback((item: Fruit) => !!item.disabled, []);
const handleSearchValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
},
[],
);
const selectFruit = useCallback((selectedItem: Fruit) => {
setSearchValue(selectedItem.name);
}, []);
const renderItem = useCallback(
(fruit: Fruit) => <span>{fruit.name}</span>,
[],
);
// Don't filter results if an exact match has been entered
const shouldFilterResults = !items.find((item) => searchValue === item.name);
const results = shouldFilterResults
? items.filter((item) =>
item.name.toLowerCase().includes(searchValue.toLowerCase()),
)
: items;
return (
<ComboboxField
label='Favourite fruit'
value={searchValue}
onChange={handleSearchValueChange}
items={results}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
onSelectItem={selectFruit}
renderItem={renderItem}
/>
);
};
const meta = {
title: 'Components/Form Fields/ComboboxField',
component: ComboboxDemo,
} satisfies Meta<typeof ComboboxDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Example: Story = {};

View File

@@ -0,0 +1,408 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, useCallback, useId, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
import { matchWidth } from 'mastodon/components/dropdown/utils';
import { IconButton } from 'mastodon/components/icon_button';
import { useOnClickOutside } from 'mastodon/hooks/useOnClickOutside';
import classes from './combobox.module.scss';
import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { TextInput } from './text_input_field';
interface Item {
id: string;
}
interface ComboboxProps<
T extends Item,
> extends ComponentPropsWithoutRef<'input'> {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
isLoading?: boolean;
items: T[];
getItemId: (item: T) => string;
getIsItemDisabled?: (item: T) => boolean;
renderItem: (item: T) => React.ReactElement;
onSelectItem: (item: T) => void;
}
interface Props<T extends Item>
extends ComboboxProps<T>, CommonFieldWrapperProps {}
/**
* The combobox field allows users to select one or multiple items
* from a large list of options by searching or filtering.
*/
export const ComboboxFieldWithRef = <T extends Item>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
);
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
<T extends Item>(
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxFieldWithRef>;
displayName: string;
};
ComboboxField.displayName = 'ComboboxField';
const ComboboxWithRef = <T extends Item>(
{
value,
isLoading = false,
items,
getItemId,
getIsItemDisabled,
renderItem,
onSelectItem,
onChange,
onKeyDown,
className,
...otherProps
}: ComboboxProps<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>();
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
null,
);
const [shouldMenuOpen, setShouldMenuOpen] = useState(false);
const statusMessage = useGetA11yStatusMessage({
value,
isLoading,
itemCount: items.length,
});
const showStatusMessageInMenu =
!!statusMessage && value.length > 0 && items.length === 0;
const hasMenuContent = items.length > 0 || showStatusMessageInMenu;
const isMenuOpen = shouldMenuOpen && hasMenuContent;
const openMenu = useCallback(() => {
setShouldMenuOpen(true);
}, []);
const closeMenu = useCallback(() => {
setShouldMenuOpen(false);
}, []);
const resetHighlight = useCallback(() => {
const firstItem = items[0];
const firstItemId = firstItem ? getItemId(firstItem) : null;
setHighlightedItemId(firstItemId);
}, [getItemId, items]);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e);
resetHighlight();
setShouldMenuOpen(!!e.target.value);
},
[onChange, resetHighlight],
);
const handleHighlightItem = useCallback(
(e: React.MouseEvent<HTMLLIElement>) => {
const { itemId } = e.currentTarget.dataset;
if (itemId) {
setHighlightedItemId(itemId);
}
},
[],
);
const selectItem = useCallback(
(itemId: string | null) => {
const item = items.find((item) => item.id === itemId);
if (item) {
const isDisabled = getIsItemDisabled?.(item) ?? false;
if (!isDisabled) {
onSelectItem(item);
}
}
inputRef.current?.focus();
},
[getIsItemDisabled, items, onSelectItem],
);
const handleSelectItem = useCallback(
(e: React.MouseEvent<HTMLLIElement>) => {
const { itemId } = e.currentTarget.dataset;
selectItem(itemId ?? null);
},
[selectItem],
);
const selectHighlightedItem = useCallback(() => {
selectItem(highlightedItemId);
}, [highlightedItemId, selectItem]);
const moveHighlight = useCallback(
(direction: number) => {
if (items.length === 0) {
return;
}
const highlightedItemIndex = items.findIndex(
(item) => getItemId(item) === highlightedItemId,
);
if (highlightedItemIndex === -1) {
// If no item is highlighted yet, highlight the first or last
if (direction > 0) {
const firstItem = items.at(0);
setHighlightedItemId(firstItem ? getItemId(firstItem) : null);
} else {
const lastItem = items.at(-1);
setHighlightedItemId(lastItem ? getItemId(lastItem) : null);
}
} else {
// If there is a highlighted item, select the next or previous item
// and wrap around at the start or end:
let newIndex = highlightedItemIndex + direction;
if (newIndex >= items.length) {
newIndex = 0;
} else if (newIndex < 0) {
newIndex = items.length - 1;
}
const newHighlightedItem = items[newIndex];
setHighlightedItemId(
newHighlightedItem ? getItemId(newHighlightedItem) : null,
);
}
},
[getItemId, highlightedItemId, items],
);
useOnClickOutside(wrapperRef, closeMenu);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.key === 'ArrowUp') {
e.preventDefault();
if (isMenuOpen) {
moveHighlight(-1);
} else {
openMenu();
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (isMenuOpen) {
moveHighlight(1);
} else {
openMenu();
}
}
if (e.key === 'Tab') {
if (isMenuOpen) {
selectHighlightedItem();
closeMenu();
}
}
if (e.key === 'Enter') {
if (isMenuOpen) {
e.preventDefault();
selectHighlightedItem();
closeMenu();
}
}
if (e.key === 'Escape') {
if (isMenuOpen) {
e.preventDefault();
closeMenu();
}
}
},
[
closeMenu,
isMenuOpen,
moveHighlight,
onKeyDown,
openMenu,
selectHighlightedItem,
],
);
const mergeRefs = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref],
);
const id = useId();
const listId = `${id}-list`;
return (
<div className={classes.wrapper} ref={wrapperRef}>
<TextInput
role='combobox'
{...otherProps}
aria-controls={listId}
aria-expanded={isMenuOpen ? 'true' : 'false'}
aria-haspopup='true'
aria-activedescendant={
isMenuOpen && highlightedItemId ? highlightedItemId : undefined
}
aria-autocomplete='list'
autoComplete='off'
spellCheck='false'
value={value}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
className={classNames(classes.input, className)}
ref={mergeRefs}
/>
{hasMenuContent && (
<IconButton
title={
isMenuOpen
? intl.formatMessage({
id: 'combobox.close_results',
defaultMessage: 'Close results',
})
: intl.formatMessage({
id: 'combobox.open_results',
defaultMessage: 'Open results',
})
}
className={classes.menuButton}
icon='results'
iconComponent={
isMenuOpen ? KeyboardArrowUpIcon : KeyboardArrowDownIcon
}
onClick={isMenuOpen ? closeMenu : openMenu}
/>
)}
<span role='status' aria-live='polite' className='sr-only'>
{isMenuOpen && statusMessage}
</span>
<Overlay
flip
show={isMenuOpen}
offset={[0, 1]}
placement='bottom-start'
onHide={closeMenu}
target={inputRef as React.RefObject<HTMLInputElement>}
container={wrapperRef}
popperConfig={{
modifiers: [matchWidth],
}}
>
{({ props, placement }) => (
<div {...props} className={classNames(classes.popover, placement)}>
{showStatusMessageInMenu ? (
<span className={classes.emptyMessage}>{statusMessage}</span>
) : (
<ul role='listbox' id={listId} tabIndex={-1}>
{items.map((item) => {
const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
key={id}
role='option'
className={classes.menuItem}
aria-selected={id === highlightedItemId}
aria-disabled={isDisabled}
data-item-id={id}
onMouseEnter={handleHighlightItem}
onClick={handleSelectItem}
>
{renderItem(item)}
</li>
);
})}
</ul>
)}
</div>
)}
</Overlay>
</div>
);
};
// Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`.
export const Combobox = forwardRef(ComboboxWithRef) as {
<T extends Item>(
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxWithRef>;
displayName: string;
};
Combobox.displayName = 'Combobox';
function useGetA11yStatusMessage({
itemCount,
value,
isLoading,
}: {
itemCount: number;
value: string;
isLoading: boolean;
}): string {
const intl = useIntl();
if (isLoading) {
return intl.formatMessage({
id: 'combobox.loading',
defaultMessage: 'Loading',
});
}
if (value.length && !itemCount) {
return intl.formatMessage({
id: 'combobox.no_results_found',
defaultMessage: 'No results for this search',
});
}
if (itemCount > 0) {
return intl.formatMessage(
{
id: 'combobox.results_available',
defaultMessage:
'{count, plural, one {# suggestion} other {# suggestions}} available. Use up and down arrow keys to navigate. Press Enter key to select.',
},
{
count: itemCount,
},
);
}
return '';
}

View File

@@ -3,6 +3,7 @@ export { Fieldset } from './fieldset';
export { TextInputField, TextInput } from './text_input_field';
export { TextAreaField, TextArea } from './text_area_field';
export { CheckboxField, Checkbox } from './checkbox_field';
export { ComboboxField, Combobox } from './combobox_field';
export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field';

View File

@@ -109,6 +109,7 @@ class Status extends ImmutablePureComponent {
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
featured: PropTypes.bool,
showThread: PropTypes.bool,
showActions: PropTypes.bool,
isQuotedPost: PropTypes.bool,
@@ -557,7 +558,7 @@ class Status extends ImmutablePureComponent {
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const header = this.props.headerRenderFn
? this.props.headerRenderFn({ status, account, avatarSize, messages, onHeaderClick: this.handleHeaderClick, statusProps: this.props })
? this.props.headerRenderFn({ status, account, avatarSize, messages, onHeaderClick: this.handleHeaderClick, featured })
: (
<StatusHeader
status={status}

View File

@@ -2,6 +2,7 @@ import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from 'react';
import { defineMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { isStatusVisibility } from '@/mastodon/api_types/statuses';
@@ -15,8 +16,6 @@ import { LinkedDisplayName } from '../display_name';
import { RelativeTimestamp } from '../relative_timestamp';
import { VisibilityIcon } from '../visibility_icon';
import type { StatusProps } from './types';
export interface StatusHeaderProps {
status: Status;
account?: Account;
@@ -25,17 +24,17 @@ export interface StatusHeaderProps {
wrapperProps?: HTMLAttributes<HTMLDivElement>;
displayNameProps?: DisplayNameProps;
onHeaderClick?: MouseEventHandler<HTMLDivElement>;
className?: string;
featured?: boolean;
}
export type StatusHeaderRenderFn = (
args: StatusHeaderProps,
statusProps?: StatusProps,
) => ReactNode;
export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode;
export const StatusHeader: FC<StatusHeaderProps> = ({
status,
account,
children,
className,
avatarSize = 48,
wrapperProps,
onHeaderClick,
@@ -49,7 +48,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
onClick={onHeaderClick}
onAuxClick={onHeaderClick}
{...wrapperProps}
className='status__info'
className={classNames('status__info', className)}
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
>
<Link

View File

@@ -14,6 +14,7 @@ export interface StatusProps {
muted?: boolean;
hidden?: boolean;
unread?: boolean;
featured?: boolean;
showThread?: boolean;
showActions?: boolean;
isQuotedPost?: boolean;

View File

@@ -108,11 +108,12 @@ class StatusContent extends PureComponent {
const { status, onCollapsedToggle } = this.props;
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsible, onClick } = this.props;
const text = node.querySelector(':scope > .status__content__text');
const collapsed =
collapsible
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& (node.clientHeight > MAX_HEIGHT || (text !== null && text.scrollWidth > text.clientWidth))
&& status.get('spoiler_text').length === 0;
onCollapsedToggle(collapsed);

View File

@@ -5,9 +5,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses';
import { StatusQuoteManager } from '../components/status_quoted';
@@ -35,6 +36,7 @@ export default class StatusList extends ImmutablePureComponent {
timelineId: PropTypes.string,
lastId: PropTypes.string,
bindToDocument: PropTypes.bool,
statusProps: PropTypes.object,
};
static defaultProps = {
@@ -51,7 +53,7 @@ export default class StatusList extends ImmutablePureComponent {
};
render () {
const { statusIds, featuredStatusIds, onLoadMore, timelineId, ...other } = this.props;
const { statusIds, featuredStatusIds, onLoadMore, timelineId, statusProps, ...other } = this.props;
const { isLoading, isPartial } = other;
if (isPartial) {
@@ -83,6 +85,7 @@ export default class StatusList extends ImmutablePureComponent {
scrollKey={this.props.scrollKey}
showThread
withCounters={this.props.withCounters}
{...statusProps}
/>
);
}
@@ -90,16 +93,21 @@ export default class StatusList extends ImmutablePureComponent {
) : null;
if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => (
<StatusQuoteManager
key={`f-${statusId}`}
id={statusId}
featured
contextType={timelineId}
showThread
withCounters={this.props.withCounters}
/>
)).concat(scrollableContent);
scrollableContent = featuredStatusIds.map(statusId => {
if (statusId === TIMELINE_PINNED_VIEW_ALL) {
return <PinnedShowAllButton key={TIMELINE_PINNED_VIEW_ALL} />
}
return (
<StatusQuoteManager
key={`f-${statusId}`}
id={statusId}
featured
contextType={timelineId}
showThread
withCounters={this.props.withCounters}
{...statusProps} />
);
}).concat(scrollableContent);
}
return (

View File

@@ -1,5 +1,8 @@
import { Map as ImmutableMap } from 'immutable';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ApiQuoteJSON } from '@/mastodon/api_types/quotes';
import { accountFactoryState, statusFactoryState } from '@/testing/factories';
import type { StatusQuoteManagerProps } from './status_quoted';
@@ -10,9 +13,6 @@ const meta = {
render(args) {
return <StatusQuoteManager {...args} />;
},
args: {
id: '1',
},
parameters: {
state: {
accounts: {
@@ -21,8 +21,40 @@ const meta = {
statuses: {
'1': statusFactoryState({
id: '1',
language: 'en',
text: 'Hello world!',
}),
'2': statusFactoryState({
id: '2',
language: 'en',
text: 'Quote!',
quote: ImmutableMap({
state: 'accepted',
quoted_status: '1',
}) as unknown as ApiQuoteJSON,
}),
'1001': statusFactoryState({
id: '1001',
language: 'mn-Mong',
// meaning: Mongolia
text: 'ᠮᠤᠩᠭᠤᠯ',
}),
'1002': statusFactoryState({
id: '1002',
language: 'mn-Mong',
// meaning: All human beings are born free and equal in dignity and rights.
text: 'ᠬᠦᠮᠦᠨ ᠪᠦᠷ ᠲᠥᠷᠥᠵᠦ ᠮᠡᠨᠳᠡᠯᠡᠬᠦ ᠡᠷᠬᠡ ᠴᠢᠯᠥᠭᠡ ᠲᠡᠢ᠂ ᠠᠳᠠᠯᠢᠬᠠᠨ ᠨᠡᠷ᠎ᠡ ᠲᠥᠷᠥ ᠲᠡᠢ᠂ ᠢᠵᠢᠯ ᠡᠷᠬᠡ ᠲᠡᠢ ᠪᠠᠢᠠᠭ᠃',
}),
'1003': statusFactoryState({
id: '1003',
language: 'mn-Mong',
// meaning: Mongolia
text: 'ᠮᠤᠩᠭᠤᠯ',
quote: ImmutableMap({
state: 'accepted',
quoted_status: '1002',
}) as unknown as ApiQuoteJSON,
}),
},
},
},
@@ -32,4 +64,34 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Default: Story = {
args: {
id: '1',
},
};
export const Quote: Story = {
args: {
id: '2',
},
};
export const TraditionalMongolian: Story = {
args: {
id: '1001',
},
};
export const LongTraditionalMongolian: Story = {
args: {
id: '1002',
},
};
// TODO: fix quoted rotated Mongolian script text
// https://github.com/mastodon/mastodon/pull/37204#issuecomment-3661767226
export const QuotedTraditionalMongolian: Story = {
args: {
id: '1003',
},
};

View File

@@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import IconPinned from '@/images/icons/icon_pinned.svg?react';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import {
AdminBadge,
@@ -14,6 +15,7 @@ import {
GroupBadge,
MutedBadge,
} from '@/mastodon/components/badge';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -106,6 +108,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
<MutedBadge
key='muted-badge'
className={classNames(className, classes.badgeMuted)}
expiresAt={relationship.muting_expires_at}
/>,
);
}
@@ -118,6 +121,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
return <div className={'account__header__badges'}>{badges}</div>;
};
export const PinnedBadge: FC = () => (
<Badge
className={classes.badge}
icon={<Icon id='pinned' icon={IconPinned} />}
label={
<FormattedMessage id='account.timeline.pinned' defaultMessage='Pinned' />
}
/>
);
function isAdminBadge(role: AccountRole) {
const name = role.name.toLowerCase();
return isRedesignEnabled() && (name === 'admin' || name === 'owner');

View File

@@ -296,6 +296,11 @@ svg.badgeIcon {
text-decoration: none;
color: var(--color-text-primary);
border-radius: 0;
transition: color 0.2s ease-in-out;
&:not([aria-current='page']):is(:hover, :focus) {
color: var(--color-text-brand-soft);
}
}
:global(.active) {

View File

@@ -3,6 +3,7 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { List as ImmutableList } from 'immutable';
@@ -13,7 +14,6 @@ import {
} from '@/mastodon/actions/timelines_typed';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { RemoteHint } from '@/mastodon/components/remote_hint';
import StatusList from '@/mastodon/components/status_list';
@@ -29,6 +29,12 @@ import { useFilters } from '../hooks/useFilters';
import { FeaturedTags } from './featured_tags';
import { AccountFilters } from './filters';
import {
PinnedStatusProvider,
renderPinnedStatusHeader,
usePinnedStatusIds,
} from './pinned_statuses';
import classes from './styles.module.scss';
const emptyList = ImmutableList<string>();
@@ -50,11 +56,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
// Add this key to remount the timeline when accountId changes.
return (
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
<PinnedStatusProvider>
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
</PinnedStatusProvider>
);
};
@@ -74,11 +82,14 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const dispatch = useAppDispatch();
useEffect(() => {
if (!timeline && !!accountId) {
dispatch(expandTimelineByKey({ key }));
if (accountId) {
if (!timeline) {
dispatch(expandTimelineByKey({ key }));
}
}
}, [accountId, dispatch, key, timeline]);
@@ -91,7 +102,10 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
[accountId, dispatch, key],
);
const forceEmptyState = blockedBy || hidden || suspended;
const { isLoading: isPinnedLoading, statusIds: pinnedStatusIds } =
usePinnedStatusIds({ accountId, tagged, forceEmptyState });
const isLoading = !!timeline?.isLoading || isPinnedLoading;
return (
<Column bindToDocument={!multiColumn}>
@@ -99,25 +113,22 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
<StatusList
alwaysPrepend
prepend={
<Prepend
accountId={accountId}
tagged={tagged}
forceEmpty={forceEmptyState}
/>
}
prepend={<Prepend accountId={accountId} forceEmpty={forceEmptyState} />}
append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline'
// We want to have this component when timeline is undefined (loading),
// because if we don't the prepended component will re-render with every filter change.
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
isLoading={!!timeline?.isLoading}
featuredStatusIds={pinnedStatusIds}
isLoading={isLoading}
hasMore={!forceEmptyState && !!timeline?.hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<EmptyMessage accountId={accountId} />}
bindToDocument={!multiColumn}
timelineId='account'
withCounters
className={classNames(classes.statusWrapper)}
statusProps={{ headerRenderFn: renderPinnedStatusHeader }}
/>
</Column>
);
@@ -125,9 +136,8 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
const Prepend: FC<{
accountId: string;
tagged?: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId, tagged }) => {
}> = ({ forceEmpty, accountId }) => {
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
@@ -137,7 +147,6 @@ const Prepend: FC<{
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
<FeaturedCarousel accountId={accountId} tagged={tagged} />
</>
);
};

View File

@@ -0,0 +1,146 @@
import type { FC, ReactNode } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import IconPinned from '@/images/icons/icon_pinned.svg?react';
import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines';
import {
expandTimelineByKey,
timelineKey,
} from '@/mastodon/actions/timelines_typed';
import { Button } from '@/mastodon/components/button';
import { Icon } from '@/mastodon/components/icon';
import { StatusHeader } from '@/mastodon/components/status/header';
import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header';
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '../common';
import { PinnedBadge } from '../components/badges';
import classes from './styles.module.scss';
const PinnedStatusContext = createContext<{
showAllPinned: boolean;
onShowAllPinned: () => void;
}>({
showAllPinned: false,
onShowAllPinned: () => {
throw new Error('No onShowAllPinned provided');
},
});
export const PinnedStatusProvider: FC<{ children: ReactNode }> = ({
children,
}) => {
const [showAllPinned, setShowAllPinned] = useState(false);
const handleShowAllPinned = useCallback(() => {
setShowAllPinned(true);
}, []);
// Memoize so the context doesn't change every render.
const value = useMemo(
() => ({
showAllPinned,
onShowAllPinned: handleShowAllPinned,
}),
[handleShowAllPinned, showAllPinned],
);
return (
<PinnedStatusContext.Provider value={value}>
{children}
</PinnedStatusContext.Provider>
);
};
export function usePinnedStatusIds({
accountId,
tagged,
forceEmptyState = false,
}: {
accountId: string;
tagged?: string;
forceEmptyState?: boolean;
}) {
const pinnedKey = timelineKey({
type: 'account',
userId: accountId,
tagged,
pinned: true,
});
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(expandTimelineByKey({ key: pinnedKey }));
}, [dispatch, pinnedKey]);
const pinnedTimeline = useAppSelector((state) =>
selectTimelineByKey(state, pinnedKey),
);
const { showAllPinned } = useContext(PinnedStatusContext);
const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining.
const pinnedStatusIds = useMemo(() => {
if (!pinnedTimelineItems || forceEmptyState) {
return undefined;
}
if (pinnedTimelineItems.size <= 1 || showAllPinned) {
return pinnedTimelineItems;
}
return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL);
}, [forceEmptyState, pinnedTimelineItems, showAllPinned]);
return {
statusIds: pinnedStatusIds,
isLoading: !!pinnedTimeline?.isLoading,
showAllPinned,
};
}
export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
featured,
...args
}) => {
if (!featured) {
return <StatusHeader {...args} />;
}
return (
<StatusHeader {...args} className={classes.pinnedStatusHeader}>
<PinnedBadge />
</StatusHeader>
);
};
export const PinnedShowAllButton: FC = () => {
const { onShowAllPinned } = useContext(PinnedStatusContext);
if (!isRedesignEnabled()) {
return null;
}
return (
<Button
onClick={onShowAllPinned}
className={classNames(classes.pinnedViewAllButton, 'focusable')}
>
<Icon id='pinned' icon={IconPinned} />
<FormattedMessage
id='account.timeline.pinned.view_all'
defaultMessage='View all pinned posts'
/>
</Button>
);
};

View File

@@ -0,0 +1,52 @@
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
import type { StatusHeaderProps } from '@/mastodon/components/status/header';
import {
StatusDisplayName,
StatusEditedAt,
StatusVisibility,
} from '@/mastodon/components/status/header';
import type { Account } from '@/mastodon/models/account';
export const AccountStatusHeader: FC<StatusHeaderProps> = ({
status,
account,
children,
avatarSize = 48,
wrapperProps,
onHeaderClick,
}) => {
const statusAccount = status.get('account') as Account | undefined;
const editedAt = status.get('edited_at') as string;
return (
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div
onClick={onHeaderClick}
onAuxClick={onHeaderClick}
{...wrapperProps}
className='status__info'
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
>
<Link
to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
className='status__relative-time'
>
<StatusVisibility visibility={status.get('visibility')} />
<RelativeTimestamp timestamp={status.get('created_at') as string} />
{editedAt && <StatusEditedAt editedAt={editedAt} />}
</Link>
<StatusDisplayName
statusAccount={statusAccount}
friendAccount={account}
avatarSize={avatarSize}
/>
{children}
</div>
);
};

View File

@@ -10,6 +10,12 @@
font-weight: 500;
display: flex;
align-items: center;
transition: color 0.2s ease-in-out;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
.filterSelectIcon {
@@ -57,3 +63,57 @@
overflow: visible;
max-width: none !important;
}
.statusWrapper {
:global(.status) {
padding-left: 24px;
padding-right: 24px;
}
&:has(.pinnedViewAllButton) :global(.status):has(.pinnedStatusHeader) {
border-bottom: none;
}
article:has(.pinnedViewAllButton) {
border-bottom: 1px solid var(--color-border-primary);
}
}
.pinnedViewAllButton {
background-color: var(--color-bg-primary);
border-radius: 8px;
border: 1px solid var(--color-border-primary);
box-sizing: border-box;
color: var(--color-text-primary);
line-height: normal;
margin: 12px 24px;
padding: 8px;
transition: border-color 0.2s ease-in-out;
width: calc(100% - 48px);
&:hover,
&:focus {
background-color: inherit;
border-color: var(--color-bg-brand-base-hover);
}
}
.pinnedStatusHeader {
display: grid;
grid-template-columns: max-content auto;
grid-template-rows: 1fr 1fr;
gap: 4px;
> :global(.status__relative-time) {
grid-column: 2;
height: auto;
}
> :global(.status__display-name) {
grid-row: span 2;
}
> :global(.account-role) {
justify-self: end;
}
}

View File

@@ -1,12 +1,10 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchRelationships } from 'mastodon/actions/accounts';
@@ -14,14 +12,12 @@ import { showAlertForError } from 'mastodon/actions/alerts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { apiRequest } from 'mastodon/api';
import { apiFollowAccount } from 'mastodon/api/accounts';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
@@ -35,6 +31,8 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useSearchAccounts } from './use_search_accounts';
export const messages = defineMessages({
manageMembers: {
id: 'column.list_members',
@@ -163,10 +161,23 @@ const ListMembers: React.FC<{
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(!!id);
const [mode, setMode] = useState<Mode>('remove');
const {
accountIds: searchAccountIds = [],
isLoading: loadingSearchResults,
searchAccounts: handleSearch,
} = useSearchAccounts({
onSettled: (value) => {
if (value.trim().length === 0) {
setSearching(false);
} else {
setSearching(true);
}
},
});
useEffect(() => {
if (id) {
dispatch(fetchList(id));
@@ -206,46 +217,6 @@ const ListMembers: React.FC<{
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add' && searching) {
@@ -279,7 +250,7 @@ const ListMembers: React.FC<{
scrollKey='list_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
isLoading={loading || loadingSearchResults}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={

View File

@@ -0,0 +1,67 @@
import { useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store';
export function useSearchAccounts({
onSettled,
}: {
onSettled?: (value: string) => void;
} = {}) {
const dispatch = useAppDispatch();
const [accountIds, setAccountIds] = useState<string[]>();
const [loadingState, setLoadingState] = useState<
'idle' | 'loading' | 'error'
>('idle');
const searchRequestRef = useRef<AbortController | null>(null);
const searchAccounts = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
onSettled?.('');
return;
}
setLoadingState('loading');
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoadingState('idle');
onSettled?.(value);
})
.catch(() => {
setLoadingState('error');
onSettled?.(value);
});
},
500,
{ leading: true, trailing: true },
);
return {
searchAccounts,
accountIds,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',
};
}

View File

@@ -0,0 +1,55 @@
/**
* Handle clicks that occur outside of the element(s) provided in the first parameter
*/
import type { MutableRefObject } from 'react';
import { useEffect } from 'react';
type ElementRef = MutableRefObject<HTMLElement | null>;
export function useOnClickOutside(
excludedElementRef: ElementRef | ElementRef[] | null,
onClick: (e: MouseEvent) => void,
enabled = true,
) {
useEffect(() => {
// If the search popover is expanded, close it when tabbing or
// clicking outside of it or the search form, while allowing
// tabbing or clicking inside of the popover
if (enabled) {
function handleClickOutside(event: MouseEvent) {
const excludedRefs = Array.isArray(excludedElementRef)
? excludedElementRef
: [excludedElementRef];
for (const ref of excludedRefs) {
const excludedElement = ref?.current;
// Bail out if the clicked element or the currently focused element
// is inside of excludedElement. We're also checking the focused element
// to prevent an issue in Chrome where initiating a drag inside of an
// input (to select the text inside of it) and ending that drag outside
// of the input fires a click event, breaking our excludedElement rule.
if (
excludedElement &&
(excludedElement === event.target ||
excludedElement === document.activeElement ||
excludedElement.contains(event.target as Node) ||
excludedElement.contains(document.activeElement))
) {
return;
}
}
onClick(event);
}
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}
return () => null;
}, [enabled, excludedElementRef, onClick]);
}

View File

@@ -46,6 +46,8 @@
"account.featured.hashtags": "Хэштэгі",
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
"account.featured_tags.last_status_never": "Няма допісаў",
"account.fields.scroll_next": "Паказаць наступны",
"account.fields.scroll_prev": "Паказаць папярэдні",
"account.filters.all": "Уся актыўнасць",
"account.filters.boosts_toggle": "Паказваць пашырэнні",
"account.filters.posts_boosts": "Допісы і пашырэнні",
@@ -77,6 +79,23 @@
"account.locked_info": "Гэты ўліковы запіс пазначаны як схаваны. Уладальнік сам вырашае, хто можа падпісвацца на яго.",
"account.media": "Медыя",
"account.mention": "Згадаць @{name}",
"account.menu.add_to_list": "Дадаць у спіс…",
"account.menu.block": "Заблакіраваць профіль",
"account.menu.block_domain": "Заблакіраваць {domain}",
"account.menu.copied": "Уліковы запіс скапіраваны ў буфер абмену",
"account.menu.copy": "Скапіраваць",
"account.menu.direct": "Згадаць прыватна",
"account.menu.hide_reblogs": "Схаваць пашырэнні ў стужцы",
"account.menu.mention": "Згадаць",
"account.menu.mute": "Ігнараваць уліковы запіс",
"account.menu.open_original_page": "Паказаць на {domain}",
"account.menu.remove_follower": "Выдаліць падпісчыка",
"account.menu.report": "Паскардзіцца на профіль",
"account.menu.share": "Абагуліць…",
"account.menu.show_reblogs": "Паказваць пашырэнні ў стужцы",
"account.menu.unblock": "Разблакіраваць уліковы запіс",
"account.menu.unblock_domain": "Разблакіраваць {domain}",
"account.menu.unmute": "Не ігнараваць уліковы запіс",
"account.moved_to": "{name} указаў(-ла), што яго/яе новы ўліковы запіс цяпер:",
"account.mute": "Ігнараваць @{name}",
"account.mute_notifications_short": "Не апавяшчаць",
@@ -217,17 +236,34 @@
"collections.collection_description": "Апісанне",
"collections.collection_name": "Назва",
"collections.collection_topic": "Тэма",
"collections.content_warning": "Папярэджанне аб змесціве",
"collections.continue": "Працягнуць",
"collections.create.accounts_subtitle": "Можна дадаць толькі ўліковыя запісы, на якія Вы падпісаныя і якія далі дазвол на тое, каб іх можна было знайсці.",
"collections.create.accounts_title": "Каго Вы дадасце ў гэтую калекцыю?",
"collections.create.basic_details_title": "Асноўныя звесткі",
"collections.create.settings_title": "Налады",
"collections.create.steps": "Крок {step}/{total}",
"collections.create_a_collection_hint": "Стварыце калекцыю, каб параіць або падзяліцца сваімі любімымі ўліковымі запісамі з іншымі.",
"collections.create_collection": "Стварыць калекцыю",
"collections.delete_collection": "Выдаліць калекцыю",
"collections.description_length_hint": "Максімум 100 сімвалаў",
"collections.edit_details": "Змяніць асноўныя звесткі",
"collections.edit_settings": "Змяніць налады",
"collections.error_loading_collections": "Адбылася памылка падчас загрузкі Вашых калекцый.",
"collections.manage_accounts": "Кіраванне ўліковымі запісамі",
"collections.manage_accounts_in_collection": "Кіраванне ўліковымі запісамі ў гэтай калекцыі",
"collections.mark_as_sensitive": "Пазначыць як далікатную",
"collections.mark_as_sensitive_hint": "Схаваць апісанне калекцыі і ўліковыя запісы за банерам з папярэджаннем. Назва калекцыі застанецца бачнай.",
"collections.name_length_hint": "Максімум 100 сімвалаў",
"collections.new_collection": "Новая калекцыя",
"collections.no_collections_yet": "Пакуль няма калекцый.",
"collections.topic_hint": "Дадайце хэштэг, які дапаможа іншым зразумець галоўную тэму гэтай калекцыі.",
"collections.view_collection": "Глядзець калекцыю",
"collections.visibility_public": "Публічная",
"collections.visibility_public_hint": "Можна знайсці ў пошуку і іншых месцах, дзе з'яўляюцца рэкамендацыі.",
"collections.visibility_title": "Бачнасць",
"collections.visibility_unlisted": "Схаваная",
"collections.visibility_unlisted_hint": "Бачная ўсім, у каго ёсць спасылка. Схаваная ад пошуку і рэкамендацый.",
"column.about": "Пра нас",
"column.blocks": "Заблакіраваныя карыстальнікі",
"column.bookmarks": "Закладкі",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Blokeret domæne",
"account.badges.group": "Gruppe",
"account.badges.muted": "Skjult",
"account.badges.muted_until": "Skjult indtil {until}",
"account.block": "Blokér @{name}",
"account.block_domain": "Blokér domænet {domain}",
"account.block_short": "Bloker",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Domain blockiert",
"account.badges.group": "Gruppe",
"account.badges.muted": "Stummgeschaltet",
"account.badges.muted_until": "Stummgeschaltet bis {until}",
"account.block": "@{name} blockieren",
"account.block_domain": "{domain} blockieren",
"account.block_short": "Blockieren",
@@ -238,7 +239,7 @@
"collections.collection_topic": "Thema",
"collections.content_warning": "Inhaltswarnung",
"collections.continue": "Fortfahren",
"collections.create.accounts_subtitle": "Du kannst nur Profile hinzufügen, denen du folgst und das Hinzufügen gestatten.",
"collections.create.accounts_subtitle": "Du kannst nur Profile hinzufügen, denen du folgst und die das Hinzufügen gestatten.",
"collections.create.accounts_title": "Wen möchtest du in dieser Sammlung präsentieren?",
"collections.create.basic_details_title": "Allgemeine Informationen",
"collections.create.settings_title": "Einstellungen",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Αποκλεισμένος τομέας",
"account.badges.group": "Ομάδα",
"account.badges.muted": "Σε σίγαση",
"account.badges.muted_until": "Σε σίγαση μέχρι {until}",
"account.block": "Αποκλεισμός @{name}",
"account.block_domain": "Αποκλεισμός τομέα {domain}",
"account.block_short": "Αποκλεισμός",
@@ -559,8 +560,8 @@
"hints.profiles.see_more_followers": "Δες περισσότερους ακόλουθους στο {domain}",
"hints.profiles.see_more_follows": "Δες περισσότερα άτομα που ακολουθούνται στο {domain}",
"hints.profiles.see_more_posts": "Δες περισσότερες αναρτήσεις στο {domain}",
"home.column_settings.show_quotes": "Εμφάνιση παραθεμάτων",
"home.column_settings.show_reblogs": "Εμφάνιση προωθήσεων",
"home.column_settings.show_quotes": "Εμφάνιση παραθέσεων",
"home.column_settings.show_reblogs": "Εμφάνιση ενισχύσεων",
"home.column_settings.show_replies": "Εμφάνιση απαντήσεων",
"home.hide_announcements": "Απόκρυψη ανακοινώσεων",
"home.pending_critical_update.body": "Παρακαλούμε ενημέρωσε τον διακομιστή Mastodon σου το συντομότερο δυνατόν!",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Blocked domain",
"account.badges.group": "Group",
"account.badges.muted": "Muted",
"account.badges.muted_until": "Muted until {until}",
"account.block": "Block @{name}",
"account.block_domain": "Block domain {domain}",
"account.block_short": "Block",
@@ -122,6 +123,8 @@
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
"account.timeline.pinned": "Pinned",
"account.timeline.pinned.view_all": "View all pinned posts",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unblock_domain_short": "Unblock",
@@ -294,6 +297,11 @@
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_search.cancel": "Cancel",
"combobox.close_results": "Close results",
"combobox.loading": "Loading",
"combobox.no_results_found": "No results for this search",
"combobox.open_results": "Open results",
"combobox.results_available": "{count, plural, one {# suggestion} other {# suggestions}} available. Use up and down arrow keys to navigate. Press Enter key to select.",
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
"community.column_settings.remote_only": "Remote only",
@@ -461,6 +469,7 @@
"empty_column.notification_requests": "All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"empty_state.no_results": "No results",
"error.no_hashtag_feed_access": "Join or log in to view and follow this hashtag.",
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
"error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Dominio bloqueado",
"account.badges.group": "Grupo",
"account.badges.muted": "Cuenta silenciada",
"account.badges.muted_until": "Cuenta silenciada hasta {until}",
"account.block": "Bloquear a @{name}",
"account.block_domain": "Bloquear dominio {domain}",
"account.block_short": "Bloquear",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Dominio bloqueado",
"account.badges.group": "Grupo",
"account.badges.muted": "Silenciado",
"account.badges.muted_until": "Silenciado hasta {until}",
"account.block": "Bloquear a @{name}",
"account.block_domain": "Bloquear dominio {domain}",
"account.block_short": "Bloquear",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Dominio bloqueado",
"account.badges.group": "Grupo",
"account.badges.muted": "Silenciado",
"account.badges.muted_until": "Silenciado hasta {until}",
"account.block": "Bloquear a @{name}",
"account.block_domain": "Bloquear dominio {domain}",
"account.block_short": "Bloquear",
@@ -46,6 +47,8 @@
"account.featured.hashtags": "Etiquetas",
"account.featured_tags.last_status_at": "Última publicación el {date}",
"account.featured_tags.last_status_never": "Sin publicaciones",
"account.fields.scroll_next": "Mostrar siguiente",
"account.fields.scroll_prev": "Mostrar anterior",
"account.filters.all": "Toda la actividad",
"account.filters.boosts_toggle": "Mostrar impulsos",
"account.filters.posts_boosts": "Publicaciones e impulsos",
@@ -77,6 +80,23 @@
"account.locked_info": "El estado de privacidad de esta cuenta está configurado como bloqueado. El proprietario debe revisar manualmente quien puede seguirle.",
"account.media": "Multimedia",
"account.mention": "Mencionar a @{name}",
"account.menu.add_to_list": "Añadir a lista…",
"account.menu.block": "Bloquear cuenta",
"account.menu.block_domain": "Bloquear {domain}",
"account.menu.copied": "Enlace a la cuenta copiado al portapapeles",
"account.menu.copy": "Copiar enlace",
"account.menu.direct": "Mencionar en privado",
"account.menu.hide_reblogs": "Ocultar impulsos en la cronología",
"account.menu.mention": "Mencionar",
"account.menu.mute": "Silenciar cuenta",
"account.menu.open_original_page": "Ver en {domain}",
"account.menu.remove_follower": "Eliminar seguidor",
"account.menu.report": "Denunciar cuenta",
"account.menu.share": "Compartir…",
"account.menu.show_reblogs": "Mostrar impulsos en la cronología",
"account.menu.unblock": "Desbloquear cuenta",
"account.menu.unblock_domain": "Desbloquear {domain}",
"account.menu.unmute": "Dejar de silenciar cuenta",
"account.moved_to": "{name} ha indicado que su nueva cuenta es ahora:",
"account.mute": "Silenciar a @{name}",
"account.mute_notifications_short": "Silenciar notificaciones",
@@ -217,17 +237,34 @@
"collections.collection_description": "Descripción",
"collections.collection_name": "Nombre",
"collections.collection_topic": "Tema",
"collections.content_warning": "Advertencia de contenido",
"collections.continue": "Continuar",
"collections.create.accounts_subtitle": "Solo pueden añadirse cuentas que sigues y que han activado el descubrimiento.",
"collections.create.accounts_title": "¿A quién presentarás en esta colección?",
"collections.create.basic_details_title": "Datos básicos",
"collections.create.settings_title": "Ajustes",
"collections.create.steps": "Paso {step}/{total}",
"collections.create_a_collection_hint": "Crea una colección para recomendar o compartir tus cuentas favoritas con otros.",
"collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Limitado a 100 caracteres",
"collections.edit_details": "Cambiar datos básicos",
"collections.edit_settings": "Cambiar ajustes",
"collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.",
"collections.manage_accounts": "Administrar cuentas",
"collections.manage_accounts_in_collection": "Administrar cuentas en esta colección",
"collections.mark_as_sensitive": "Marcar como sensible",
"collections.mark_as_sensitive_hint": "Oculta la descripción de la colección y las cuentas detrás de una advertencia de contenido. El nombre de la colección seguirá siendo visible.",
"collections.name_length_hint": "Limitado a 100 caracteres",
"collections.new_collection": "Nueva colección",
"collections.no_collections_yet": "Aún no hay colecciones.",
"collections.topic_hint": "Añadir una etiqueta que ayude a otros a entender el tema principal de esta colección.",
"collections.view_collection": "Ver colección",
"collections.visibility_public": "Pública",
"collections.visibility_public_hint": "Puede mostrarse en los resultados de búsqueda y en otros lugares donde aparezcan recomendaciones.",
"collections.visibility_title": "Visibilidad",
"collections.visibility_unlisted": "No listada",
"collections.visibility_unlisted_hint": "Visible para cualquiera con un enlace. Excluida de los resultados de búsqueda y recomendaciones.",
"column.about": "Acerca de",
"column.blocks": "Usuarios bloqueados",
"column.bookmarks": "Marcadores",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Estetty verkkotunnus",
"account.badges.group": "Ryhmä",
"account.badges.muted": "Mykistetty",
"account.badges.muted_until": "Mykistetty {until} asti",
"account.block": "Estä @{name}",
"account.block_domain": "Estä verkkotunnus {domain}",
"account.block_short": "Estä",

View File

@@ -236,17 +236,34 @@
"collections.collection_description": "Description",
"collections.collection_name": "Nom",
"collections.collection_topic": "Sujet",
"collections.content_warning": "Avertissement au public",
"collections.continue": "Continuer",
"collections.create.accounts_subtitle": "Seuls les comptes que vous suivez et qui ont autorisé leur découverte peuvent être ajoutés.",
"collections.create.accounts_title": "Qui voulez-vous mettre en avant dans cette collection?",
"collections.create.basic_details_title": "Informations générales",
"collections.create.settings_title": "Paramètres",
"collections.create.steps": "Étape {step}/{total}",
"collections.create_a_collection_hint": "Créer une collection pour recommander ou partager vos comptes préférés.",
"collections.create_collection": "Créer une collection",
"collections.delete_collection": "Supprimer la collection",
"collections.description_length_hint": "Maximum 100 caractères",
"collections.edit_details": "Modifier les informations générales",
"collections.edit_settings": "Modifier les paramètres",
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",
"collections.manage_accounts": "Gérer les comptes",
"collections.manage_accounts_in_collection": "Gérer les comptes de cette collection",
"collections.mark_as_sensitive": "Marquer comme sensible",
"collections.mark_as_sensitive_hint": "Masque la description et les comptes de la collection derrière un avertissement de contenu. Le titre reste visible.",
"collections.mark_as_sensitive_hint": "Masque la description et les comptes de la collection derrière un avertissement au public. Le titre reste visible.",
"collections.name_length_hint": "Maximum 100 caractères",
"collections.new_collection": "Nouvelle collection",
"collections.no_collections_yet": "Aucune collection pour le moment.",
"collections.topic_hint": "Ajouter un hashtag pour aider les autres personnes à comprendre le sujet de la collection.",
"collections.view_collection": "Voir la collection",
"collections.visibility_public": "Publique",
"collections.visibility_public_hint": "Visible dans les résultats de recherche et les recommandations.",
"collections.visibility_title": "Visibilité",
"collections.visibility_unlisted": "Non listée",
"collections.visibility_unlisted_hint": "Visible pour les personnes ayant le lien. N'apparaît pas dans les résultats de recherche et les recommandations.",
"column.about": "À propos",
"column.blocks": "Comptes bloqués",
"column.bookmarks": "Signets",
@@ -818,7 +835,7 @@
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer de rechercher ou de parcourir la page \"Explorer\" pour trouver des personnes à suivre, ou réessayer plus tard.",
"onboarding.follows.search": "Recherche",
"onboarding.follows.title": "Suivre des personnes pour commencer",
"onboarding.profile.discoverable": "Rendre mon profil découvrable",
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
"onboarding.profile.display_name": "Nom affiché",
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",

View File

@@ -236,17 +236,34 @@
"collections.collection_description": "Description",
"collections.collection_name": "Nom",
"collections.collection_topic": "Sujet",
"collections.content_warning": "Avertissement au public",
"collections.continue": "Continuer",
"collections.create.accounts_subtitle": "Seuls les comptes que vous suivez et qui ont autorisé leur découverte peuvent être ajoutés.",
"collections.create.accounts_title": "Qui voulez-vous mettre en avant dans cette collection?",
"collections.create.basic_details_title": "Informations générales",
"collections.create.settings_title": "Paramètres",
"collections.create.steps": "Étape {step}/{total}",
"collections.create_a_collection_hint": "Créer une collection pour recommander ou partager vos comptes préférés.",
"collections.create_collection": "Créer une collection",
"collections.delete_collection": "Supprimer la collection",
"collections.description_length_hint": "Maximum 100 caractères",
"collections.edit_details": "Modifier les informations générales",
"collections.edit_settings": "Modifier les paramètres",
"collections.error_loading_collections": "Une erreur s'est produite durant le chargement de vos collections.",
"collections.manage_accounts": "Gérer les comptes",
"collections.manage_accounts_in_collection": "Gérer les comptes de cette collection",
"collections.mark_as_sensitive": "Marquer comme sensible",
"collections.mark_as_sensitive_hint": "Masque la description et les comptes de la collection derrière un avertissement de contenu. Le titre reste visible.",
"collections.mark_as_sensitive_hint": "Masque la description et les comptes de la collection derrière un avertissement au public. Le titre reste visible.",
"collections.name_length_hint": "Maximum 100 caractères",
"collections.new_collection": "Nouvelle collection",
"collections.no_collections_yet": "Aucune collection pour le moment.",
"collections.topic_hint": "Ajouter un hashtag pour aider les autres personnes à comprendre le sujet de la collection.",
"collections.view_collection": "Voir la collection",
"collections.visibility_public": "Publique",
"collections.visibility_public_hint": "Visible dans les résultats de recherche et les recommandations.",
"collections.visibility_title": "Visibilité",
"collections.visibility_unlisted": "Non listée",
"collections.visibility_unlisted_hint": "Visible pour les personnes ayant le lien. N'apparaît pas dans les résultats de recherche et les recommandations.",
"column.about": "À propos",
"column.blocks": "Utilisateurs bloqués",
"column.bookmarks": "Marque-pages",
@@ -818,7 +835,7 @@
"onboarding.follows.empty": "Malheureusement, aucun résultat ne peut être affiché pour le moment. Vous pouvez essayer d'utiliser la recherche ou parcourir la page de découverte pour trouver des personnes à suivre, ou réessayez plus tard.",
"onboarding.follows.search": "Recherche",
"onboarding.follows.title": "Suivre des personnes pour commencer",
"onboarding.profile.discoverable": "Rendre mon profil découvrable",
"onboarding.profile.discoverable": "Permettre de découvrir mon profil",
"onboarding.profile.discoverable_hint": "Lorsque vous acceptez d'être découvert sur Mastodon, vos messages peuvent apparaître dans les résultats de recherche et les tendances, et votre profil peut être suggéré à des personnes ayant des intérêts similaires aux vôtres.",
"onboarding.profile.display_name": "Nom affiché",
"onboarding.profile.display_name_hint": "Votre nom complet ou votre nom rigolo…",

View File

@@ -236,17 +236,34 @@
"collections.collection_description": "Cur síos",
"collections.collection_name": "Ainm",
"collections.collection_topic": "Topaic",
"collections.content_warning": "Rabhadh ábhair",
"collections.continue": "Lean ar aghaidh",
"collections.create.accounts_subtitle": "Ní féidir ach cuntais a leanann tú atá roghnaithe le fionnachtain a chur leis.",
"collections.create.accounts_title": "Cé a bheidh le feiceáil agat sa bhailiúchán seo?",
"collections.create.basic_details_title": "Sonraí bunúsacha",
"collections.create.settings_title": "Socruithe",
"collections.create.steps": "Céim {step}/{total}",
"collections.create_a_collection_hint": "Cruthaigh bailiúchán chun do chuntais is fearr leat a mholadh nó a roinnt le daoine eile.",
"collections.create_collection": "Cruthaigh bailiúchán",
"collections.delete_collection": "Scrios bailiúchán",
"collections.description_length_hint": "Teorainn 100 carachtar",
"collections.edit_details": "Cuir sonraí bunúsacha in eagar",
"collections.edit_settings": "Socruithe a chur in eagar",
"collections.error_loading_collections": "Tharla earráid agus iarracht á déanamh do bhailiúcháin a luchtú.",
"collections.manage_accounts": "Bainistigh cuntais",
"collections.manage_accounts_in_collection": "Bainistigh cuntais sa bhailiúchán seo",
"collections.mark_as_sensitive": "Marcáil mar íogair",
"collections.mark_as_sensitive_hint": "Folaíonn sé cur síos agus cuntais an bhailiúcháin taobh thiar de rabhadh ábhair. Beidh ainm an bhailiúcháin le feiceáil fós.",
"collections.name_length_hint": "Teorainn 100 carachtar",
"collections.new_collection": "Bailiúchán nua",
"collections.no_collections_yet": "Gan aon bhailiúcháin fós.",
"collections.topic_hint": "Cuir haischlib leis a chabhraíonn le daoine eile príomhábhar an bhailiúcháin seo a thuiscint.",
"collections.view_collection": "Féach ar bhailiúchán",
"collections.visibility_public": "Poiblí",
"collections.visibility_public_hint": "Infheicthe i dtorthaí cuardaigh agus i réimsí eile ina bhfuil moltaí le feiceáil.",
"collections.visibility_title": "Infheictheacht",
"collections.visibility_unlisted": "Gan liosta",
"collections.visibility_unlisted_hint": "Infheicthe ag aon duine a bhfuil nasc aige. I bhfolach ó thorthaí cuardaigh agus ó mholtaí.",
"column.about": "Maidir le",
"column.blocks": "Úsáideoirí blocáilte",
"column.bookmarks": "Leabharmharcanna",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Dominio bloqueado",
"account.badges.group": "Grupo",
"account.badges.muted": "Silenciada",
"account.badges.muted_until": "Silenciada ate o {until}",
"account.block": "Bloquear @{name}",
"account.block_domain": "Bloquear o dominio {domain}",
"account.block_short": "Bloquear",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Útilokað lén",
"account.badges.group": "Hópur",
"account.badges.muted": "Þaggað",
"account.badges.muted_until": "Þaggað til {until}",
"account.block": "Loka á @{name}",
"account.block_domain": "Útiloka lénið {domain}",
"account.block_short": "Útiloka",
@@ -238,15 +239,20 @@
"collections.collection_topic": "Umfjöllunarefni",
"collections.content_warning": "Viðvörun vegna efnis",
"collections.continue": "Halda áfram",
"collections.create.accounts_subtitle": "Einungis er hægt að bæta við notendum sem hafa samþykkt að vera með í opinberri birtingu.",
"collections.create.accounts_title": "Hvern vilt þú gera áberandi í þessu safni?",
"collections.create.basic_details_title": "Grunnupplýsingar",
"collections.create.settings_title": "Stillingar",
"collections.create.steps": "Skref {step}/{total}",
"collections.create_a_collection_hint": "Búðu til safn með eftirlætisnotendunum þínum til að deila eða mæla með við aðra.",
"collections.create_collection": "Búa til safn",
"collections.delete_collection": "Eyða safni",
"collections.description_length_hint": "100 stafa takmörk",
"collections.edit_details": "Breyta grunnupplýsingum",
"collections.edit_settings": "Breyta stillingum",
"collections.error_loading_collections": "Villa kom upp þegar reynt var að hlaða inn söfnunum þínum.",
"collections.manage_accounts": "Sýsla með notandaaðganga",
"collections.manage_accounts_in_collection": "Sýsla með notendaaðganga í þessu safni",
"collections.mark_as_sensitive": "Merkja sem viðkvæmt",
"collections.mark_as_sensitive_hint": "Felur lýsingu safnsins og notendur á bakvið aðvörun vegna efnis. Nafn safnsins verður áfram sýnilegt.",
"collections.name_length_hint": "100 stafa takmörk",
@@ -255,8 +261,10 @@
"collections.topic_hint": "Bættu við myllumerki sem hjálpar öðrum að skilja aðalefni þessa safns.",
"collections.view_collection": "Skoða safn",
"collections.visibility_public": "Opinbert",
"collections.visibility_public_hint": "Hægt að finna í leitarniðurstöðum og öðrum þeim þáttum þar sem meðmæli birtast.",
"collections.visibility_title": "Sýnileiki",
"collections.visibility_unlisted": "Óskráð",
"collections.visibility_unlisted_hint": "Sýnilegt hverjum þeim sem eru með tengil. Falið í leitarniðurstöðum og meðmælum.",
"column.about": "Um hugbúnaðinn",
"column.blocks": "Útilokaðir notendur",
"column.bookmarks": "Bókamerki",

View File

@@ -236,17 +236,23 @@
"collections.collection_description": "Descrizione",
"collections.collection_name": "Nome",
"collections.collection_topic": "Argomento",
"collections.continue": "Continua",
"collections.create.settings_title": "Impostazioni",
"collections.create_a_collection_hint": "Crea una collezione per consigliare o condividere i tuoi account preferiti con altri.",
"collections.create_collection": "Crea la collezione",
"collections.delete_collection": "Cancella la collezione",
"collections.description_length_hint": "Limite di 100 caratteri",
"collections.edit_settings": "Modifica impostazioni",
"collections.error_loading_collections": "Si è verificato un errore durante il tentativo di caricare le tue collezioni.",
"collections.manage_accounts": "Gestisci account",
"collections.mark_as_sensitive": "Segna come sensibile",
"collections.mark_as_sensitive_hint": "Nasconde la descrizione e gli account della collezione dietro un avviso di contenuto. Il nome della collezione rimarrà visibile.",
"collections.name_length_hint": "Limite di 100 caratteri",
"collections.new_collection": "Nuova collezione",
"collections.no_collections_yet": "Nessuna collezione ancora.",
"collections.topic_hint": "Aggiungi un hashtag che aiuti gli altri a comprendere l'argomento principale di questa collezione.",
"collections.view_collection": "Visualizza la collezione",
"collections.visibility_title": "Visibilità",
"column.about": "Info",
"column.blocks": "Utenti bloccati",
"column.bookmarks": "Segnalibri",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Përkatësi e bllokuar",
"account.badges.group": "Grup",
"account.badges.muted": "E heshtuar",
"account.badges.muted_until": "Heshtuar deri më {until}",
"account.block": "Blloko @{name}",
"account.block_domain": "Blloko përkatësinë {domain}",
"account.block_short": "Bllokoje",

View File

@@ -236,17 +236,34 @@
"collections.collection_description": "Açıklama",
"collections.collection_name": "Ad",
"collections.collection_topic": "Konu",
"collections.content_warning": "İçerik uyarısı",
"collections.continue": "Devam et",
"collections.create.accounts_subtitle": "Yalnızca keşif seçeneğini etkinleştirmiş takip ettiğiniz hesaplar eklenebilir.",
"collections.create.accounts_title": "Bu koleksiyonda kimleri öne çıkaracaksınız?",
"collections.create.basic_details_title": "Temel bilgiler",
"collections.create.settings_title": "Ayarlar",
"collections.create.steps": "Adım {step}/{total}",
"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.edit_details": "Temel bilgileri düzenle",
"collections.edit_settings": "Ayarları düzenle",
"collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.",
"collections.manage_accounts": "Hesapları yönet",
"collections.manage_accounts_in_collection": "Bu koleksiyondaki hesapları yönet",
"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.new_collection": "Yeni koleksiyon",
"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",
"collections.visibility_public": "Herkese açık",
"collections.visibility_public_hint": "Arama sonuçlarında ve önerilerin görüntülendiği diğer alanlarda keşfedilebilir.",
"collections.visibility_title": "Görünürlük",
"collections.visibility_unlisted": "Listelenmemiş",
"collections.visibility_unlisted_hint": "Bağlantısı olan herkes tarafından görülebilir. Arama sonuçlarında ve önerilerde gizlenir.",
"column.about": "Hakkında",
"column.blocks": "Engellenen kullanıcılar",
"column.bookmarks": "Yer İşaretleri",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "Máy chủ đã chặn",
"account.badges.group": "Nhóm",
"account.badges.muted": "Đã phớt lờ",
"account.badges.muted_until": "Bị ẩn tới {until}",
"account.block": "Chặn @{name}",
"account.block_domain": "Chặn mọi thứ từ {domain}",
"account.block_short": "Chặn",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "已屏蔽域名",
"account.badges.group": "群组",
"account.badges.muted": "已停止提醒",
"account.badges.muted_until": "隐藏直到 {until}",
"account.block": "屏蔽 @{name}",
"account.block_domain": "屏蔽 {domain} 实例",
"account.block_short": "屏蔽",

View File

@@ -23,6 +23,7 @@
"account.badges.domain_blocked": "已封鎖網域",
"account.badges.group": "群組",
"account.badges.muted": "已靜音",
"account.badges.muted_until": "靜音直至 {until}",
"account.block": "封鎖 @{name}",
"account.block_domain": "封鎖來自 {domain} 網域之所有內容",
"account.block_short": "封鎖",
@@ -960,7 +961,7 @@
"search.quick_action.status_search": "符合的嘟文 {x}",
"search.search_or_paste": "搜尋或輸入網址",
"search_popout.full_text_search_disabled_message": "{domain} 上無法使用。",
"search_popout.full_text_search_logged_out_message": "僅於登入時能使用。",
"search_popout.full_text_search_logged_out_message": "功能僅限登入後使用。",
"search_popout.language_code": "ISO 語言代碼 (ISO language code)",
"search_popout.options": "搜尋選項",
"search_popout.quick_actions": "快捷操作",

View File

@@ -15,8 +15,9 @@ const RelationshipFactory = Record<RelationshipShape>({
following: false,
id: '',
languages: null,
muting_notifications: false,
muting: false,
muting_notifications: false,
muting_expires_at: null,
note: '',
notifying: false,
requested_by: false,

View File

@@ -6,6 +6,9 @@ $backdrop-blur-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
// Language codes that uses CJK fonts
$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
// Language codes that is written vertically
$vertical-lr-langs: mn-Mong;
// Variables for components
$media-modal-media-max-width: 100%;

View File

@@ -1343,6 +1343,38 @@ body > [data-popper-placement] {
}
}
@each $lang in $vertical-lr-langs {
// writing-mode and width must be applied to a single element. When
// this element inherits a text direction that is opposite to its own,
// the start of this element's text is cut off.
.status:not(.status--is-quote)
> .status__content
> .status__content__text:lang(#{$lang}),
.conversation
> .conversation__content
> .status__content
> .status__content__text:lang(#{$lang}) {
writing-mode: vertical-lr;
width: 100%; // detecting overflow
max-width: calc(100% - mod(100%, 22px)); // avoid cut-offs
max-height: 209px; // roughly above 500 characters, readable
overflow-x: hidden; // read more
}
.autosuggest-textarea > .autosuggest-textarea__textarea:lang(#{$lang}) {
writing-mode: vertical-lr;
min-height: 209px; // writable
}
.detailed-status > .status__content > .status__content__text:lang(#{$lang}) {
writing-mode: vertical-lr;
width: 100%; // detecting overflow
max-height: 50vh;
overflow-x: auto;
}
}
.status__content.status__content--collapsed {
max-height: 22px * 15; // 15 lines is roughly above 500 characters
}

View File

@@ -99,10 +99,11 @@ export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
blocking: false,
blocked_by: false,
languages: null,
muting: false,
muting_notifications: false,
muting_expires_at: null,
note: '',
requested_by: false,
muting: false,
requested: false,
domain_blocking: false,
endorsed: false,

View File

@@ -39,6 +39,7 @@ module Account::Mappings
Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
mapping[mute.target_account_id] = {
notifications: mute.hide_notifications?,
expires_at: mute.expires_at,
}
end
end

View File

@@ -10,7 +10,7 @@ module RelationshipCacheable
private
def remove_relationship_cache
Rails.cache.delete(['relationship', account_id, target_account_id])
Rails.cache.delete(['relationship', target_account_id, account_id])
Rails.cache.delete(['relationships', account_id, target_account_id])
Rails.cache.delete(['relationships', target_account_id, account_id])
end
end

View File

@@ -114,6 +114,6 @@ class AccountRelationshipsPresenter
end
def relationship_cache_key(account_id)
['relationship', @current_account_id, account_id]
['relationships', @current_account_id, account_id]
end
end

View File

@@ -4,7 +4,7 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
# Please update `app/javascript/mastodon/api_types/relationships.ts` when making changes to the attributes
attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by,
:blocking, :blocked_by, :muting, :muting_notifications,
:blocking, :blocked_by, :muting, :muting_notifications, :muting_expires_at,
:requested, :requested_by, :domain_blocking, :endorsed, :note
def id
@@ -52,6 +52,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
(instance_options[:relationships].muting[object.id] || {})[:notifications] || false
end
def muting_expires_at
(instance_options[:relationships].muting[object.id] || {})[:expires_at]&.iso8601
end
def requested
instance_options[:relationships].requested[object.id] ? true : false
end

View File

@@ -208,7 +208,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
Tag.find_or_create_by_names([tag])
rescue ActiveRecord::RecordInvalid
[]
end
end.uniq
return unless @status.distributable?

View File

@@ -66,10 +66,10 @@ class MoveWorker
# Clear any relationship cache, since callbacks are not called
Rails.cache.delete_multi(follows.flat_map do |follow|
[
['relationship', follow.account_id, follow.target_account_id],
['relationship', follow.target_account_id, follow.account_id],
['relationship', follow.account_id, @target_account.id],
['relationship', @target_account.id, follow.account_id],
['relationships', follow.account_id, follow.target_account_id],
['relationships', follow.target_account_id, follow.account_id],
['relationships', follow.account_id, @target_account.id],
['relationships', @target_account.id, follow.account_id],
]
end)
end

View File

@@ -14,6 +14,11 @@ be:
many: Падпісчыкаў
one: Падпісчык
other: Падпісчыкі
following:
few: Падпіскі
many: Падпісак
one: Падпіска
other: Падпісак
instance_actor_flash: Гэты ўліковы запіс - лічбавы аватар, неабходны для рэпрэзентацыі самога сервера, а не якой-небудзь асобы. Ён выкарыстоўваецца для федэралізацыі і не можа быць замарожаны.
last_active: апошняя актыўнасць
link_verified_on: Права ўласнасці на гэтую спасылку праверана %{date}

View File

@@ -1761,10 +1761,10 @@ el:
setup: Ρύθμιση
wrong_code: Ο κωδικός που έβαλες ήταν άκυρος! Είναι σωστή ώρα στον διακομιστή και τη συσκευή;
pagination:
newer: Νεότερο
next: Επόμενο
older: Παλιότερο
prev: Προηγούμενο
newer: Νεότερες
next: Επόμενη
older: Παλαιότερες
prev: Προηγούμενη
truncate: "&hellip;"
polls:
errors:

View File

@@ -1791,9 +1791,9 @@ fr-CA:
privacy:
hint_html: "<strong>Personnalisez la façon dont votre profil et vos messages peuvent être découverts.</strong> Mastodon peut vous aider à atteindre un public plus large lorsque certains paramètres sont activés. Prenez le temps de les examiner pour vous assurer quils sont configurés comme vous le souhaitez."
privacy: Confidentialité
privacy_hint_html: Contrôlez ce que vous souhaitez divulguer. Les gens découvrent des profils intéressants en parcourant ceux suivis par dautres personnes et des applications sympas en voyant lesquelles sont utilisées par dautres pour publier des messages, mais vous préférez peut-être ne pas dévoiler ces informations.
privacy_hint_html: Contrôler ce que vous souhaitez divulguer. Les utilisateur·rice·s découvrent des profils intéressants en parcourant ceux suivis par dautres personnes et des applications sympas en voyant celles utilisées pour publier des messages, mais vous préférez peut-être ne pas dévoiler ces informations.
reach: Portée
reach_hint_html: Contrôlez si vous souhaitez être découvert et suivi par de nouvelles personnes. Voulez-vous que vos publications apparaissent sur lécran Explorer ? Voulez-vous que dautres personnes vous voient dans leurs recommandations de suivi ? Souhaitez-vous approuver automatiquement tous les nouveaux abonnés ou avoir un contrôle granulaire sur chacun dentre eux ?
reach_hint_html: Contrôler si vous souhaitez être découvert et suivi par de nouvelles personnes. Voulez-vous que vos messages puissent apparaître dans les tendances ? Voulez-vous que dautres personnes vous voient dans leurs recommandations de suivi ? Souhaitez-vous approuver automatiquement tous les nouveaux abonnés ou avoir un contrôle granulaire sur chacun dentre eux ?
search: Recherche
search_hint_html: Contrôlez la façon dont vous voulez être retrouvé. Voulez-vous que les gens vous trouvent selon ce que vous avez publié publiquement ? Voulez-vous que des personnes extérieures à Mastodon trouvent votre profil en faisant des recherches sur le web ? Noubliez pas que lexclusion totale de tous les moteurs de recherche ne peut être garantie pour les informations publiques.
title: Vie privée et visibilité

View File

@@ -1791,9 +1791,9 @@ fr:
privacy:
hint_html: "<strong>Personnalisez la façon dont votre profil et vos messages peuvent être découverts.</strong> Mastodon peut vous aider à atteindre un public plus large lorsque certains paramètres sont activés. Prenez le temps de les examiner pour vous assurer quils sont configurés comme vous le souhaitez."
privacy: Confidentialité
privacy_hint_html: Contrôlez ce que vous souhaitez divulguer. Les gens découvrent des profils intéressants en parcourant ceux suivis par dautres personnes et des applications sympas en voyant lesquelles sont utilisées par dautres pour publier des messages, mais vous préférez peut-être ne pas dévoiler ces informations.
privacy_hint_html: Contrôler ce que vous souhaitez divulguer. Les utilisateur·rice·s découvrent des profils intéressants en parcourant ceux suivis par dautres personnes et des applications sympas en voyant celles utilisées pour publier des messages, mais vous préférez peut-être ne pas dévoiler ces informations.
reach: Portée
reach_hint_html: Contrôlez si vous souhaitez être découvert et suivi par de nouvelles personnes. Voulez-vous que vos publications apparaissent sur lécran Explorer ? Voulez-vous que dautres personnes vous voient dans leurs recommandations de suivi ? Souhaitez-vous approuver automatiquement tous les nouveaux abonnés ou avoir un contrôle granulaire sur chacun dentre eux ?
reach_hint_html: Contrôler si vous souhaitez être découvert et suivi par de nouvelles personnes. Voulez-vous que vos messages puissent apparaître dans les tendances ? Voulez-vous que dautres personnes vous voient dans leurs recommandations de suivi ? Souhaitez-vous approuver automatiquement tous les nouveaux abonnés ou avoir un contrôle granulaire sur chacun dentre eux ?
search: Recherche
search_hint_html: Contrôlez la façon dont vous voulez être retrouvé. Voulez-vous que les gens vous trouvent selon ce que vous avez publié publiquement ? Voulez-vous que des personnes extérieures à Mastodon trouvent votre profil en faisant des recherches sur le web ? Noubliez pas que lexclusion totale de tous les moteurs de recherche ne peut être garantie pour les informations publiques.
title: Vie privée et visibilité
@@ -2107,7 +2107,7 @@ fr:
disable: Vous ne pouvez plus utiliser votre compte, mais votre profil et d'autres données restent intacts. Vous pouvez demander une sauvegarde de vos données, modifier les paramètres de votre compte ou supprimer votre compte.
mark_statuses_as_sensitive: Certains de vos messages ont été marqués comme sensibles par l'équipe de modération de %{instance}. Cela signifie qu'il faudra cliquer sur le média pour pouvoir en afficher un aperçu. Vous pouvez marquer les médias comme sensibles vous-même lorsque vous posterez à l'avenir.
sensitive: Désormais, tous vos fichiers multimédias téléchargés seront marqués comme sensibles et cachés derrière un avertissement à cliquer.
silence: Vous pouvez toujours utiliser votre compte, mais seules les personnes qui vous suivent déjà verront vos messages sur ce serveur, et vous pourriez être exclu de diverses fonctions de découverte. Cependant, d'autres personnes peuvent toujours vous suivre manuellement.
silence: Vous pouvez toujours utiliser votre compte, mais seules les personnes qui vous suivent déjà verront vos messages sur ce serveur, et votre compte pourra être exclu des fonctions de découverte. Cependant, les utilisateur·rice·s peuvent toujours vous suivre manuellement.
suspend: Vous ne pouvez plus utiliser votre compte, votre profil et vos autres données ne sont plus accessibles. Vous pouvez toujours vous connecter pour demander une sauvegarde de vos données jusqu'à leur suppression complète dans environ 30 jours, mais nous conserverons certaines données de base pour vous empêcher d'échapper à la suspension.
reason: 'Motif :'
statuses: 'Messages cités :'

View File

@@ -93,7 +93,7 @@ fr-CA:
content_cache_retention_period: Tous les messages provenant d'autres serveurs (y compris les partages et les réponses) seront supprimés passé le nombre de jours spécifié, sans tenir compte de l'interaction de l'utilisateur·rice local·e avec ces messages. Cela inclut les messages qu'un·e utilisateur·rice aurait marqué comme signets ou comme favoris. Les mentions privées entre utilisateur·rice·s de différentes instances seront également perdues et impossibles à restaurer. L'utilisation de ce paramètre est destinée à des instances spécifiques et contrevient à de nombreuses attentes des utilisateurs lorsqu'elle est appliquée à des fins d'utilisation ordinaires.
custom_css: Vous pouvez appliquer des styles personnalisés sur la version Web de Mastodon.
favicon: WEBP, PNG, GIF ou JPG. Remplace la favicon Mastodon par défaut avec une icône personnalisée.
landing_page: Sélectionner la page à afficher aux nouveaux visiteurs quand ils arrivent sur votre serveur. Pour utiliser « Tendances » les tendances doivent être activées dans les paramètres de découverte. Pour utiliser « Fil local » le paramètre « Accès au flux en direct de ce serveur » doit être défini sur « Tout le monde » dans les paramètres de découverte.
landing_page: Sélectionner la page à afficher aux nouveaux visiteur·euse·s quand ils arrivent sur votre serveur. Pour utiliser « Tendances » les tendances doivent être activées dans les paramètres de découverte. Pour utiliser « Fil local » le paramètre « Accès au flux en direct de ce serveur » doit être défini sur « Tout le monde » dans les paramètres de découverte.
mascot: Remplace l'illustration dans l'interface Web avancée.
media_cache_retention_period: Les fichiers médias des messages publiés par des utilisateurs distants sont mis en cache sur votre serveur. Lorsque cette valeur est positive, les médias sont supprimés au terme du nombre de jours spécifié. Si les données des médias sont demandées après leur suppression, elles seront téléchargées à nouveau, dans la mesure où le contenu source est toujours disponible. En raison des restrictions concernant la fréquence à laquelle les cartes de prévisualisation des liens interrogent des sites tiers, il est recommandé de fixer cette valeur à au moins 14 jours, faute de quoi les cartes de prévisualisation des liens ne seront pas mises à jour à la demande avant cette échéance.
min_age: Les utilisateurs seront invités à confirmer leur date de naissance lors de l'inscription

View File

@@ -93,12 +93,12 @@ fr:
content_cache_retention_period: Tous les messages provenant d'autres serveurs (y compris les partages et les réponses) seront supprimés passé le nombre de jours spécifié, sans tenir compte de l'interaction de l'utilisateur·rice local·e avec ces messages. Cela inclut les messages qu'un·e utilisateur·rice aurait marqué comme signets ou comme favoris. Les mentions privées entre utilisateur·rice·s de différentes instances seront également perdues et impossibles à restaurer. L'utilisation de ce paramètre est destinée à des instances spécifiques et contrevient à de nombreuses attentes des utilisateurs lorsqu'elle est appliquée à des fins d'utilisation ordinaires.
custom_css: Vous pouvez appliquer des styles personnalisés sur la version Web de Mastodon.
favicon: WEBP, PNG, GIF ou JPG. Remplace la favicon Mastodon par défaut avec une icône personnalisée.
landing_page: Sélectionner la page à afficher aux nouveaux visiteurs quand ils arrivent sur votre serveur. Pour utiliser « Tendances » les tendances doivent être activées dans les paramètres de découverte. Pour utiliser « Fil local » le paramètre « Accès au flux en direct de ce serveur » doit être défini sur « Tout le monde » dans les paramètres de découverte.
landing_page: Sélectionner la page à afficher aux nouveaux visiteur·euse·s quand ils arrivent sur votre serveur. Pour utiliser « Tendances » les tendances doivent être activées dans les paramètres de découverte. Pour utiliser « Fil local » le paramètre « Accès au flux en direct de ce serveur » doit être défini sur « Tout le monde » dans les paramètres de découverte.
mascot: Remplace l'illustration dans l'interface Web avancée.
media_cache_retention_period: Les fichiers médias des messages publiés par des utilisateurs distants sont mis en cache sur votre serveur. Lorsque cette valeur est positive, les médias sont supprimés au terme du nombre de jours spécifié. Si les données des médias sont demandées après leur suppression, elles seront téléchargées à nouveau, dans la mesure où le contenu source est toujours disponible. En raison des restrictions concernant la fréquence à laquelle les cartes de prévisualisation des liens interrogent des sites tiers, il est recommandé de fixer cette valeur à au moins 14 jours, faute de quoi les cartes de prévisualisation des liens ne seront pas mises à jour à la demande avant cette échéance.
min_age: Les utilisateurs seront invités à confirmer leur date de naissance lors de l'inscription
peers_api_enabled: Une liste de noms de domaine que ce serveur a rencontrés dans le fédiverse. Aucune donnée indiquant si vous vous fédérez ou non avec un serveur particulier n'est incluse ici, seulement l'information que votre serveur connaît un autre serveur. Cette option est utilisée par les services qui collectent des statistiques sur la fédération en général.
profile_directory: L'annuaire des profils répertorie tous les comptes qui choisi d'être découvrables.
profile_directory: L'annuaire des profils répertorie tous les comptes qui ont permis d'être découverts.
require_invite_text: Lorsque les inscriptions nécessitent une approbation manuelle, rendre le texte de linvitation "Pourquoi voulez-vous vous inscrire ?" obligatoire plutôt que facultatif
site_contact_email: Comment l'on peut vous joindre pour des requêtes d'assistance ou d'ordre juridique.
site_contact_username: Comment les gens peuvent vous contacter sur Mastodon.
@@ -296,7 +296,7 @@ fr:
mascot: Mascotte personnalisée (héritée)
media_cache_retention_period: Durée de rétention des médias dans le cache
min_age: Âge minimum requis
peers_api_enabled: Publie la liste des serveurs découverts dans l'API
peers_api_enabled: Publier la liste des serveurs découverts dans lAPI
profile_directory: Activer lannuaire des profils
registrations_mode: Qui peut sinscrire
remote_live_feed_access: Accès au flux en direct des autres serveurs

View File

@@ -93,13 +93,13 @@ RSpec.describe Account::Mappings do
context 'when Mute#hide_notifications?' do
let(:hide) { true }
it { is_expected.to eq(target_account_id => { notifications: true }) }
it { is_expected.to eq(target_account_id => { expires_at: nil, notifications: true }) }
end
context 'when not Mute#hide_notifications?' do
let(:hide) { false }
it { is_expected.to eq(target_account_id => { notifications: false }) }
it { is_expected.to eq(target_account_id => { expires_at: nil, notifications: false }) }
end
end

View File

@@ -259,6 +259,8 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
{ type: 'Hashtag', name: 'foo' },
{ type: 'Hashtag', name: 'bar' },
{ type: 'Hashtag', name: '#2024' },
{ type: 'Hashtag', name: 'Foo Bar' },
{ type: 'Hashtag', name: 'FooBar' },
],
}
end
@@ -270,7 +272,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
it 'updates tags and featured tags' do
expect { subject.call(status, json, json) }
.to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar'))
.to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar', 'foobar'))
.and change { status.account.featured_tags.find_by(name: 'test').statuses_count }.by(-1)
.and change { status.account.featured_tags.find_by(name: 'bar').statuses_count }.by(1)
.and change { status.account.featured_tags.find_by(name: 'bar').last_status_at }.from(nil).to(be_present)