diff --git a/app/javascript/images/icons/icon_pinned.svg b/app/javascript/images/icons/icon_pinned.svg new file mode 100644 index 0000000000..90eaa6c933 --- /dev/null +++ b/app/javascript/images/icons/icon_pinned.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index d97885bad1..c6c2a864a8 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -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 => ({ diff --git a/app/javascript/mastodon/api_types/relationships.ts b/app/javascript/mastodon/api_types/relationships.ts index 9f26a0ce9b..aa871d6f79 100644 --- a/app/javascript/mastodon/api_types/relationships.ts +++ b/app/javascript/mastodon/api_types/relationships.ts @@ -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; diff --git a/app/javascript/mastodon/components/badge.stories.tsx b/app/javascript/mastodon/components/badge.stories.tsx index aaddcaa91a..6c4921809c 100644 --- a/app/javascript/mastodon/components/badge.stories.tsx +++ b/app/javascript/mastodon/components/badge.stories.tsx @@ -8,7 +8,7 @@ const meta = { component: badges.Badge, title: 'Components/Badge', args: { - label: 'Example', + label: undefined, }, } satisfies Meta; @@ -16,16 +16,22 @@ export default meta; type Story = StoryObj; -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: , }, }; @@ -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 ; + }, +}; + export const Blocked: Story = { render(args) { return ; diff --git a/app/javascript/mastodon/components/badge.tsx b/app/javascript/mastodon/components/badge.tsx index 0ffb7baa8a..07ecdfa46c 100644 --- a/app/javascript/mastodon/components/badge.tsx +++ b/app/javascript/mastodon/components/badge.tsx @@ -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 = ({ ); -export const AdminBadge: FC> = (props) => ( +export const AdminBadge: FC> = ({ label, ...props }) => ( } label={ - + label ?? ( + + ) } {...props} /> ); -export const GroupBadge: FC> = (props) => ( +export const GroupBadge: FC> = ({ label, ...props }) => ( } label={ - + label ?? ( + + ) } {...props} /> @@ -66,21 +70,54 @@ export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( /> ); -export const MutedBadge: FC> = (props) => ( - } - label={ - - } - {...props} - /> -); +export const MutedBadge: FC< + Partial & { 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 ( + } + label={ + label ?? + (formattedDate ? ( + + ) : ( + + )) + } + {...props} + /> + ); +}; -export const BlockedBadge: FC> = (props) => ( +export const BlockedBadge: FC> = ({ label, ...props }) => ( } label={ - + label ?? ( + + ) } {...props} /> diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss new file mode 100644 index 0000000000..1707b3bc08 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -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); + } +} diff --git a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx new file mode 100644 index 0000000000..8515a6ea1a --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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: , + }, +}; diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx new file mode 100644 index 0000000000..93f034f3e9 --- /dev/null +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -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 = ( + + ), + message, + children, +}) => { + return ( +
+
+

{title}

+ {!!message &&

{message}

} +
+ + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/form_fields/combobox.module.scss b/app/javascript/mastodon/components/form_fields/combobox.module.scss new file mode 100644 index 0000000000..98c0db9f61 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox.module.scss @@ -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; +} diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx new file mode 100644 index 0000000000..412428d345 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox_field.stories.tsx @@ -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) => { + setSearchValue(event.target.value); + }, + [], + ); + + const selectFruit = useCallback((selectedItem: Fruit) => { + setSearchValue(selectedItem.name); + }, []); + + const renderItem = useCallback( + (fruit: Fruit) => {fruit.name}, + [], + ); + + // 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 ( + + ); +}; + +const meta = { + title: 'Components/Form Fields/ComboboxField', + component: ComboboxDemo, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = {}; diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx new file mode 100644 index 0000000000..7ddd019469 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -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; + isLoading?: boolean; + items: T[]; + getItemId: (item: T) => string; + getIsItemDisabled?: (item: T) => boolean; + renderItem: (item: T) => React.ReactElement; + onSelectItem: (item: T) => void; +} + +interface Props + extends ComboboxProps, 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 = ( + { id, label, hint, hasError, required, ...otherProps }: Props, + ref: React.ForwardedRef, +) => ( + + {(inputProps) => } + +); + +// 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 { + ( + props: Props & { ref?: React.ForwardedRef }, + ): ReturnType; + displayName: string; +}; + +ComboboxField.displayName = 'ComboboxField'; + +const ComboboxWithRef = ( + { + value, + isLoading = false, + items, + getItemId, + getIsItemDisabled, + renderItem, + onSelectItem, + onChange, + onKeyDown, + className, + ...otherProps + }: ComboboxProps, + ref: React.ForwardedRef, +) => { + const intl = useIntl(); + const wrapperRef = useRef(null); + const inputRef = useRef(); + + const [highlightedItemId, setHighlightedItemId] = useState( + 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) => { + onChange(e); + resetHighlight(); + setShouldMenuOpen(!!e.target.value); + }, + [onChange, resetHighlight], + ); + + const handleHighlightItem = useCallback( + (e: React.MouseEvent) => { + 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) => { + 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) => { + 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 ( +
+ + {hasMenuContent && ( + + )} + + {isMenuOpen && statusMessage} + + } + container={wrapperRef} + popperConfig={{ + modifiers: [matchWidth], + }} + > + {({ props, placement }) => ( +
+ {showStatusMessageInMenu ? ( + {statusMessage} + ) : ( +
    + {items.map((item) => { + const id = getItemId(item); + const isDisabled = getIsItemDisabled?.(item); + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
  • + {renderItem(item)} +
  • + ); + })} +
+ )} +
+ )} +
+
+ ); +}; + +// 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 { + ( + props: ComboboxProps & { ref?: React.ForwardedRef }, + ): ReturnType; + 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 ''; +} diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index e44525e383..ef4a6567e5 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -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'; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 88b6168229..fd2054b066 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -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 }) : ( ; displayNameProps?: DisplayNameProps; onHeaderClick?: MouseEventHandler; + className?: string; + featured?: boolean; } -export type StatusHeaderRenderFn = ( - args: StatusHeaderProps, - statusProps?: StatusProps, -) => ReactNode; +export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode; export const StatusHeader: FC = ({ status, account, children, + className, avatarSize = 48, wrapperProps, onHeaderClick, @@ -49,7 +48,7 @@ export const StatusHeader: FC = ({ 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 */ > .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); diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 049905dc08..817a9e9ce4 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -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 => ( - - )).concat(scrollableContent); + scrollableContent = featuredStatusIds.map(statusId => { + if (statusId === TIMELINE_PINNED_VIEW_ALL) { + return + } + return ( + + ); + }).concat(scrollableContent); } return ( diff --git a/app/javascript/mastodon/components/status_quoted.stories.tsx b/app/javascript/mastodon/components/status_quoted.stories.tsx index aa17a5422c..5b78d3a3c5 100644 --- a/app/javascript/mastodon/components/status_quoted.stories.tsx +++ b/app/javascript/mastodon/components/status_quoted.stories.tsx @@ -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 ; }, - 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; -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', + }, +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/badges.tsx b/app/javascript/mastodon/features/account_timeline/components/badges.tsx index d48dc669f5..09335cee88 100644 --- a/app/javascript/mastodon/features/account_timeline/components/badges.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/badges.tsx @@ -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 }) => { , ); } @@ -118,6 +121,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => { return
{badges}
; }; +export const PinnedBadge: FC = () => ( + } + label={ + + } + /> +); + function isAdminBadge(role: AccountRole) { const name = role.name.toLowerCase(); return isRedesignEnabled() && (name === 'admin' || name === 'owner'); diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 944f6f7a7a..6f71a5ae89 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -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) { diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx index c0a4cf5735..ff5783903c 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -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(); @@ -50,11 +56,13 @@ const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { // Add this key to remount the timeline when accountId changes. return ( - + + + ); }; @@ -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 ( @@ -99,25 +113,22 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ - } + prepend={} append={} 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={} bindToDocument={!multiColumn} timelineId='account' withCounters + className={classNames(classes.statusWrapper)} + statusProps={{ headerRenderFn: renderPinnedStatusHeader }} /> ); @@ -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 ; } @@ -137,7 +147,6 @@ const Prepend: FC<{ - ); }; diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx new file mode 100644 index 0000000000..eec92cdc38 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx @@ -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 ( + + {children} + + ); +}; + +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 ; + } + return ( + + + + ); +}; + +export const PinnedShowAllButton: FC = () => { + const { onShowAllPinned } = useContext(PinnedStatusContext); + + if (!isRedesignEnabled()) { + return null; + } + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx new file mode 100644 index 0000000000..5f0ff88685 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx @@ -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 = ({ + 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 */ +
+ + + + {editedAt && } + + + + + {children} +
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index c35b46524e..35bf330166 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/features/lists/members.tsx b/app/javascript/mastodon/features/lists/members.tsx index c974f31c0b..dbc110abe5 100644 --- a/app/javascript/mastodon/features/lists/members.tsx +++ b/app/javascript/mastodon/features/lists/members.tsx @@ -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([]); - const [searchAccountIds, setSearchAccountIds] = useState([]); const [loading, setLoading] = useState(!!id); const [mode, setMode] = useState('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(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('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={ diff --git a/app/javascript/mastodon/features/lists/use_search_accounts.ts b/app/javascript/mastodon/features/lists/use_search_accounts.ts new file mode 100644 index 0000000000..d9783a8a63 --- /dev/null +++ b/app/javascript/mastodon/features/lists/use_search_accounts.ts @@ -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(); + const [loadingState, setLoadingState] = useState< + 'idle' | 'loading' | 'error' + >('idle'); + + const searchRequestRef = useRef(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('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', + }; +} diff --git a/app/javascript/mastodon/hooks/useOnClickOutside.ts b/app/javascript/mastodon/hooks/useOnClickOutside.ts new file mode 100644 index 0000000000..ad964978c8 --- /dev/null +++ b/app/javascript/mastodon/hooks/useOnClickOutside.ts @@ -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; + +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]); +} diff --git a/app/javascript/mastodon/locales/be.json b/app/javascript/mastodon/locales/be.json index 54d31d3f7d..ef6bc2e837 100644 --- a/app/javascript/mastodon/locales/be.json +++ b/app/javascript/mastodon/locales/be.json @@ -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": "Закладкі", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 3209224d32..f70bd1a62f 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -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", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 224f634106..dbcfd20221 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -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", diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 0fb6b06f3e..01a17b4ac5 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -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 σου το συντομότερο δυνατόν!", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e89efa84d5..a646d479d7 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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.", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index a863f98714..5cb8f0adca 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -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", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 5e8e8578e3..d73452ece3 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -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", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 7a01a770e9..c78a84f1d1 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -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", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 91070912a7..a3fec5dea8 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -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ä", diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index 16a8c62f61..494b6c07fe 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -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…", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 9104d1a0aa..13d24fdf56 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -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…", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index 03b3ddcc61..0b56e4ff5a 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -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", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index b948ad144c..1a9a932dc5 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -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", diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json index 10c9f13d49..9d635cc89d 100644 --- a/app/javascript/mastodon/locales/is.json +++ b/app/javascript/mastodon/locales/is.json @@ -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", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index e2ed20eabe..c802e8ab0b 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -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", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 9467af52a3..2a75afbff4 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -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", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 7452870778..aeef82ed51 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -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", diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json index 89b861d057..0e455379e5 100644 --- a/app/javascript/mastodon/locales/vi.json +++ b/app/javascript/mastodon/locales/vi.json @@ -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", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index fc203fbde6..eabda892f8 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -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": "屏蔽", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index ae35edfb6b..c415d77672 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -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": "快捷操作", diff --git a/app/javascript/mastodon/models/relationship.ts b/app/javascript/mastodon/models/relationship.ts index 115b278738..450f408b33 100644 --- a/app/javascript/mastodon/models/relationship.ts +++ b/app/javascript/mastodon/models/relationship.ts @@ -15,8 +15,9 @@ const RelationshipFactory = Record({ following: false, id: '', languages: null, - muting_notifications: false, muting: false, + muting_notifications: false, + muting_expires_at: null, note: '', notifying: false, requested_by: false, diff --git a/app/javascript/styles/mastodon/_variables.scss b/app/javascript/styles/mastodon/_variables.scss index d561f71454..7a947891d5 100644 --- a/app/javascript/styles/mastodon/_variables.scss +++ b/app/javascript/styles/mastodon/_variables.scss @@ -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%; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 93d77e2905..acec3bc2d6 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 } diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 6f2a45e58f..7855157f6a 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -99,10 +99,11 @@ export const relationshipsFactory: FactoryFunction = ({ 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, diff --git a/app/models/concerns/account/mappings.rb b/app/models/concerns/account/mappings.rb index b8b43cad7c..b44ff9c844 100644 --- a/app/models/concerns/account/mappings.rb +++ b/app/models/concerns/account/mappings.rb @@ -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 diff --git a/app/models/concerns/relationship_cacheable.rb b/app/models/concerns/relationship_cacheable.rb index c32a8d62c6..9aa933aa5c 100644 --- a/app/models/concerns/relationship_cacheable.rb +++ b/app/models/concerns/relationship_cacheable.rb @@ -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 diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index 8482ef54da..f06aeb8674 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -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 diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index 4d7ed75935..221da77916 100644 --- a/app/serializers/rest/relationship_serializer.rb +++ b/app/serializers/rest/relationship_serializer.rb @@ -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 diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 166db90e0b..2fed3fdae9 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -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? diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index 81274a7936..faf576c731 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -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 diff --git a/config/locales/be.yml b/config/locales/be.yml index c309e335bd..7bad3e0432 100644 --- a/config/locales/be.yml +++ b/config/locales/be.yml @@ -14,6 +14,11 @@ be: many: Падпісчыкаў one: Падпісчык other: Падпісчыкі + following: + few: Падпіскі + many: Падпісак + one: Падпіска + other: Падпісак instance_actor_flash: Гэты ўліковы запіс - лічбавы аватар, неабходны для рэпрэзентацыі самога сервера, а не якой-небудзь асобы. Ён выкарыстоўваецца для федэралізацыі і не можа быць замарожаны. last_active: апошняя актыўнасць link_verified_on: Права ўласнасці на гэтую спасылку праверана %{date} diff --git a/config/locales/el.yml b/config/locales/el.yml index d097ada0fb..b91beb13ad 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -1761,10 +1761,10 @@ el: setup: Ρύθμιση wrong_code: Ο κωδικός που έβαλες ήταν άκυρος! Είναι σωστή ώρα στον διακομιστή και τη συσκευή; pagination: - newer: Νεότερο - next: Επόμενο - older: Παλιότερο - prev: Προηγούμενο + newer: Νεότερες + next: Επόμενη + older: Παλαιότερες + prev: Προηγούμενη truncate: "…" polls: errors: diff --git a/config/locales/fr-CA.yml b/config/locales/fr-CA.yml index b6913688e6..473d89cfe3 100644 --- a/config/locales/fr-CA.yml +++ b/config/locales/fr-CA.yml @@ -1791,9 +1791,9 @@ fr-CA: privacy: hint_html: "Personnalisez la façon dont votre profil et vos messages peuvent être découverts. 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 qu’ils 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 d’autres personnes et des applications sympas en voyant lesquelles sont utilisées par d’autres 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 d’autres 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 d’autres 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 d’entre 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 d’autres 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 d’entre 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 ? N’oubliez pas que l’exclusion totale de tous les moteurs de recherche ne peut être garantie pour les informations publiques. title: Vie privée et visibilité diff --git a/config/locales/fr.yml b/config/locales/fr.yml index e08a646864..391de6bafd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1791,9 +1791,9 @@ fr: privacy: hint_html: "Personnalisez la façon dont votre profil et vos messages peuvent être découverts. 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 qu’ils 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 d’autres personnes et des applications sympas en voyant lesquelles sont utilisées par d’autres 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 d’autres 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 d’autres 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 d’entre 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 d’autres 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 d’entre 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 ? N’oubliez pas que l’exclusion 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 :' diff --git a/config/locales/simple_form.fr-CA.yml b/config/locales/simple_form.fr-CA.yml index 6a9a4f5afa..1367ccfce6 100644 --- a/config/locales/simple_form.fr-CA.yml +++ b/config/locales/simple_form.fr-CA.yml @@ -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 diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index a69f19ac0e..e3ffcc5632 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -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 l’invitation "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 l’API profile_directory: Activer l’annuaire des profils registrations_mode: Qui peut s’inscrire remote_live_feed_access: Accès au flux en direct des autres serveurs diff --git a/spec/models/concerns/account/mappings_spec.rb b/spec/models/concerns/account/mappings_spec.rb index 18c936b892..80c66aefde 100644 --- a/spec/models/concerns/account/mappings_spec.rb +++ b/spec/models/concerns/account/mappings_spec.rb @@ -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 diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index cceeda1bb1..475c4b71f9 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -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)