Merge commit 'd0502ac3c1630e281fda5492cbc28390262b0aeb' into glitch-soc/merge-upstream
This commit is contained in:
commit
d62a7775b5
@ -754,7 +754,7 @@ GEM
|
|||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.6)
|
rspec-support (3.13.6)
|
||||||
rubocop (1.82.1)
|
rubocop (1.84.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@ -762,7 +762,7 @@ GEM
|
|||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.48.0, < 2.0)
|
rubocop-ast (>= 1.49.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.0)
|
rubocop-ast (1.49.0)
|
||||||
|
|||||||
@ -233,6 +233,7 @@ module LanguagesHelper
|
|||||||
'es-AR': 'Español (Argentina)',
|
'es-AR': 'Español (Argentina)',
|
||||||
'es-MX': 'Español (México)',
|
'es-MX': 'Español (México)',
|
||||||
'fr-CA': 'Français (Canadien)',
|
'fr-CA': 'Français (Canadien)',
|
||||||
|
'nan-TW': '臺語 (Hô-ló話)',
|
||||||
'pt-BR': 'Português (Brasil)',
|
'pt-BR': 'Português (Brasil)',
|
||||||
'pt-PT': 'Português (Portugal)',
|
'pt-PT': 'Português (Portugal)',
|
||||||
'sr-Latn': 'Srpski (latinica)',
|
'sr-Latn': 'Srpski (latinica)',
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type {
|
|||||||
ApiWrappedCollectionJSON,
|
ApiWrappedCollectionJSON,
|
||||||
ApiCollectionWithAccountsJSON,
|
ApiCollectionWithAccountsJSON,
|
||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
ApiPatchCollectionPayload,
|
ApiUpdateCollectionPayload,
|
||||||
ApiCollectionsJSON,
|
ApiCollectionsJSON,
|
||||||
} from '../api_types/collections';
|
} from '../api_types/collections';
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
|||||||
export const apiUpdateCollection = ({
|
export const apiUpdateCollection = ({
|
||||||
id,
|
id,
|
||||||
...collection
|
...collection
|
||||||
}: ApiPatchCollectionPayload) =>
|
}: ApiUpdateCollectionPayload) =>
|
||||||
apiRequestPut<ApiWrappedCollectionJSON>(
|
apiRequestPut<ApiWrappedCollectionJSON>(
|
||||||
`v1_alpha/collections/${id}`,
|
`v1_alpha/collections/${id}`,
|
||||||
collection,
|
collection,
|
||||||
@ -29,7 +29,7 @@ export const apiDeleteCollection = (collectionId: string) =>
|
|||||||
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
||||||
|
|
||||||
export const apiGetCollection = (collectionId: string) =>
|
export const apiGetCollection = (collectionId: string) =>
|
||||||
apiRequestGet<ApiCollectionWithAccountsJSON[]>(
|
apiRequestGet<ApiCollectionWithAccountsJSON>(
|
||||||
`v1_alpha/collections/${collectionId}`,
|
`v1_alpha/collections/${collectionId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -70,10 +70,10 @@ type CommonPayloadFields = Pick<
|
|||||||
ApiCollectionJSON,
|
ApiCollectionJSON,
|
||||||
'name' | 'description' | 'sensitive' | 'discoverable'
|
'name' | 'description' | 'sensitive' | 'discoverable'
|
||||||
> & {
|
> & {
|
||||||
tag?: string;
|
tag_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ApiPatchCollectionPayload extends Partial<CommonPayloadFields> {
|
export interface ApiUpdateCollectionPayload extends Partial<CommonPayloadFields> {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export { TextInputField } from './text_input_field';
|
export { TextInputField } from './text_input_field';
|
||||||
export { TextAreaField } from './text_area_field';
|
export { TextAreaField } from './text_area_field';
|
||||||
|
export { ToggleField, PlainToggleField } from './toggle_field';
|
||||||
export { SelectField } from './select_field';
|
export { SelectField } from './select_field';
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import type { FC, Key, MouseEventHandler } from 'react';
|
import type { FC, Key, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||||
|
|
||||||
import { MiniCard } from '.';
|
import { MiniCard } from '.';
|
||||||
import type { MiniCardProps } from '.';
|
import type { MiniCardProps } from '.';
|
||||||
import classes from './styles.module.css';
|
import classes from './styles.module.css';
|
||||||
@ -66,156 +67,3 @@ export const MiniCardList: FC<MiniCardListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useOverflow() {
|
|
||||||
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
|
||||||
const [hiddenCount, setHiddenCount] = useState(0);
|
|
||||||
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
|
||||||
|
|
||||||
// This is the item container element.
|
|
||||||
const listRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
// The main recalculation function.
|
|
||||||
const handleRecalculate = useCallback(() => {
|
|
||||||
const listEle = listRef.current;
|
|
||||||
if (!listEle) return;
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setHiddenIndex(-1);
|
|
||||||
setHiddenCount(0);
|
|
||||||
setMaxWidth('none');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate the width via the parent element, minus the more button, minus the padding.
|
|
||||||
const maxWidth =
|
|
||||||
(listEle.parentElement?.offsetWidth ?? 0) -
|
|
||||||
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
|
||||||
4;
|
|
||||||
if (maxWidth <= 0) {
|
|
||||||
reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through children until we exceed max width.
|
|
||||||
let visible = 0;
|
|
||||||
let index = 0;
|
|
||||||
let totalWidth = 0;
|
|
||||||
for (const child of listEle.children) {
|
|
||||||
if (child instanceof HTMLElement) {
|
|
||||||
const rightOffset = child.offsetLeft + child.offsetWidth;
|
|
||||||
if (rightOffset <= maxWidth) {
|
|
||||||
visible += 1;
|
|
||||||
totalWidth = rightOffset;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All are visible, so remove max-width restriction.
|
|
||||||
if (visible === listEle.children.length) {
|
|
||||||
reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the width to avoid wrapping, and set hidden count.
|
|
||||||
setHiddenIndex(index);
|
|
||||||
setHiddenCount(listEle.children.length - visible);
|
|
||||||
setMaxWidth(totalWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set up observers to watch for size and content changes.
|
|
||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
||||||
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
|
||||||
|
|
||||||
// Helper to get or create the resize observer.
|
|
||||||
const resizeObserver = useCallback(() => {
|
|
||||||
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
|
||||||
handleRecalculate,
|
|
||||||
));
|
|
||||||
return observer;
|
|
||||||
}, [handleRecalculate]);
|
|
||||||
|
|
||||||
// Iterate through children and observe them for size changes.
|
|
||||||
const handleChildrenChange = useCallback(() => {
|
|
||||||
const listEle = listRef.current;
|
|
||||||
const observer = resizeObserver();
|
|
||||||
|
|
||||||
if (listEle) {
|
|
||||||
for (const child of listEle.children) {
|
|
||||||
if (child instanceof HTMLElement) {
|
|
||||||
observer.observe(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleRecalculate();
|
|
||||||
}, [handleRecalculate, resizeObserver]);
|
|
||||||
|
|
||||||
// Helper to get or create the mutation observer.
|
|
||||||
const mutationObserver = useCallback(() => {
|
|
||||||
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
|
||||||
handleChildrenChange,
|
|
||||||
));
|
|
||||||
return observer;
|
|
||||||
}, [handleChildrenChange]);
|
|
||||||
|
|
||||||
// Set up observers.
|
|
||||||
const handleObserve = useCallback(() => {
|
|
||||||
if (wrapperRef.current) {
|
|
||||||
resizeObserver().observe(wrapperRef.current);
|
|
||||||
}
|
|
||||||
if (listRef.current) {
|
|
||||||
mutationObserver().observe(listRef.current, { childList: true });
|
|
||||||
handleChildrenChange();
|
|
||||||
}
|
|
||||||
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
|
||||||
|
|
||||||
// Watch the wrapper for size changes, and recalculate when it resizes.
|
|
||||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
|
||||||
const wrapperRefCallback = useCallback(
|
|
||||||
(node: HTMLElement | null) => {
|
|
||||||
if (node) {
|
|
||||||
wrapperRef.current = node;
|
|
||||||
handleObserve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleObserve],
|
|
||||||
);
|
|
||||||
|
|
||||||
// If there are changes to the children, recalculate which are visible.
|
|
||||||
const listRefCallback = useCallback(
|
|
||||||
(node: HTMLElement | null) => {
|
|
||||||
if (node) {
|
|
||||||
listRef.current = node;
|
|
||||||
handleObserve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleObserve],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleObserve();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (resizeObserverRef.current) {
|
|
||||||
resizeObserverRef.current.disconnect();
|
|
||||||
resizeObserverRef.current = null;
|
|
||||||
}
|
|
||||||
if (mutationObserverRef.current) {
|
|
||||||
mutationObserverRef.current.disconnect();
|
|
||||||
mutationObserverRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [handleObserve]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hiddenCount,
|
|
||||||
hasOverflow: hiddenCount > 0,
|
|
||||||
wrapperRef: wrapperRefCallback,
|
|
||||||
hiddenIndex,
|
|
||||||
maxWidth,
|
|
||||||
listRef: listRefCallback,
|
|
||||||
recalculate: handleRecalculate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 8px;
|
padding: 6px 8px;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import type { IconProp } from '../icon';
|
import type { IconProp } from '../icon';
|
||||||
@ -23,7 +24,7 @@ export interface TagProps {
|
|||||||
|
|
||||||
export const Tag = forwardRef<
|
export const Tag = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
TagProps & ComponentPropsWithoutRef<'button'>
|
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
|
||||||
>(({ name, active, icon, className, children, ...props }, ref) => {
|
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return null;
|
return null;
|
||||||
@ -34,6 +35,7 @@ export const Tag = forwardRef<
|
|||||||
type='button'
|
type='button'
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(className, classes.tag, active && classes.active)}
|
className={classNames(className, classes.tag, active && classes.active)}
|
||||||
|
aria-pressed={active}
|
||||||
>
|
>
|
||||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||||
{typeof name === 'string' ? `#${name}` : name}
|
{typeof name === 'string' ? `#${name}` : name}
|
||||||
@ -45,10 +47,13 @@ Tag.displayName = 'Tag';
|
|||||||
|
|
||||||
export const EditableTag = forwardRef<
|
export const EditableTag = forwardRef<
|
||||||
HTMLSpanElement,
|
HTMLSpanElement,
|
||||||
TagProps & {
|
OmitUnion<
|
||||||
onRemove: () => void;
|
ComponentPropsWithoutRef<'span'>,
|
||||||
removeIcon?: IconProp;
|
TagProps & {
|
||||||
} & ComponentPropsWithoutRef<'span'>
|
onRemove: () => void;
|
||||||
|
removeIcon?: IconProp;
|
||||||
|
}
|
||||||
|
>
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useCallback } from 'react';
|
import { forwardRef, useCallback } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import classes from './style.module.css';
|
import classes from './style.module.css';
|
||||||
import { EditableTag, Tag } from './tag';
|
import { EditableTag, Tag } from './tag';
|
||||||
import type { TagProps } from './tag';
|
import type { TagProps } from './tag';
|
||||||
@ -17,31 +19,39 @@ export type TagsProps = {
|
|||||||
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
|
||||||
if (onRemove) {
|
({ tags, active, onRemove, className, ...props }, ref) => {
|
||||||
|
if (onRemove) {
|
||||||
|
return (
|
||||||
|
<div className={classNames(classes.tagsWrapper, className)}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<MappedTag
|
||||||
|
key={tag.name}
|
||||||
|
active={tag.name === active}
|
||||||
|
onRemove={onRemove}
|
||||||
|
{...tag}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.tagsWrapper}>
|
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<MappedTag
|
<Tag
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
active={tag.name === active}
|
active={tag.name === active}
|
||||||
onRemove={onRemove}
|
|
||||||
{...tag}
|
{...tag}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
return (
|
Tags.displayName = 'Tags';
|
||||||
<div className={classes.tagsWrapper}>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|||||||
@ -162,11 +162,13 @@ export const AccountHeader: React.FC<{
|
|||||||
{!suspendedOrHidden && (
|
{!suspendedOrHidden && (
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div className='account__header__bio'>
|
<div className='account__header__bio'>
|
||||||
{me && account.id !== me && isRedesignEnabled() ? (
|
{me &&
|
||||||
<AccountNoteRedesign accountId={accountId} />
|
account.id !== me &&
|
||||||
) : (
|
(isRedesignEnabled() ? (
|
||||||
<AccountNote accountId={accountId} />
|
<AccountNoteRedesign accountId={accountId} />
|
||||||
)}
|
) : (
|
||||||
|
<AccountNote accountId={accountId} />
|
||||||
|
))}
|
||||||
|
|
||||||
<AccountBio
|
<AccountBio
|
||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { FC } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import type { NavLinkProps } from 'react-router-dom';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { isRedesignEnabled } from '../common';
|
import { isRedesignEnabled } from '../common';
|
||||||
@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
|||||||
if (isRedesignEnabled()) {
|
if (isRedesignEnabled()) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.tabs}>
|
<div className={classes.tabs}>
|
||||||
<NavLink exact to={`/@${acct}`}>
|
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink exact to={`/@${acct}/media`}>
|
<NavLink exact to={`/@${acct}/media`}>
|
||||||
@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
|
||||||
|
match?.url === location.pathname ||
|
||||||
|
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { FC, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
|
||||||
|
import { useAppHistory } from '@/mastodon/components/router';
|
||||||
|
import { Tag } from '@/mastodon/components/tags/tag';
|
||||||
|
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||||
|
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import { useFilters } from '../hooks/useFilters';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
// Fetch tags.
|
||||||
|
const featuredTags = useAppSelector((state) =>
|
||||||
|
selectAccountFeaturedTags(state, accountId),
|
||||||
|
);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchFeaturedTags({ accountId }));
|
||||||
|
}, [accountId, dispatch]);
|
||||||
|
|
||||||
|
// Get list of tags with overflow handling.
|
||||||
|
const [showOverflow, setShowOverflow] = useState(false);
|
||||||
|
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
|
||||||
|
useOverflow();
|
||||||
|
|
||||||
|
// Handle whether to show all tags.
|
||||||
|
const handleOverflowClick: MouseEventHandler = useCallback(() => {
|
||||||
|
setShowOverflow(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { onClick, currentTag } = useTagNavigate();
|
||||||
|
|
||||||
|
if (featuredTags.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.tagsWrapper} ref={wrapperRef}>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
classes.tagsList,
|
||||||
|
showOverflow && classes.tagsListShowAll,
|
||||||
|
)}
|
||||||
|
style={{ maxWidth }}
|
||||||
|
ref={listRef}
|
||||||
|
>
|
||||||
|
{featuredTags.map(({ id, name }, index) => (
|
||||||
|
<Tag
|
||||||
|
name={name}
|
||||||
|
key={id}
|
||||||
|
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
active={currentTag === name}
|
||||||
|
data-name={name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!showOverflow && hiddenCount > 0 && (
|
||||||
|
<Tag
|
||||||
|
onClick={handleOverflowClick}
|
||||||
|
name={
|
||||||
|
<FormattedMessage
|
||||||
|
id='featured_tags.more_items'
|
||||||
|
defaultMessage='+{count}'
|
||||||
|
values={{ count: hiddenCount }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function useTagNavigate() {
|
||||||
|
// Get current account, tag, and filters.
|
||||||
|
const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
|
||||||
|
const { boosts, replies } = useFilters();
|
||||||
|
|
||||||
|
const history = useAppHistory();
|
||||||
|
|
||||||
|
const handleTagClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const name = event.currentTarget.getAttribute('data-name');
|
||||||
|
if (!name || !acct) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether to navigate to or from the tag.
|
||||||
|
let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
|
||||||
|
if (name === tagged) {
|
||||||
|
url = `/@${acct}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append filters.
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (boosts) {
|
||||||
|
params.append('boosts', '1');
|
||||||
|
}
|
||||||
|
if (replies) {
|
||||||
|
params.append('replies', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
pathname: url,
|
||||||
|
search: params.toString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[acct, tagged, boosts, replies, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onClick: handleTagClick,
|
||||||
|
currentTag: tagged,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ import { AccountHeader } from '../components/account_header';
|
|||||||
import { LimitedAccountHint } from '../components/limited_account_hint';
|
import { LimitedAccountHint } from '../components/limited_account_hint';
|
||||||
import { useFilters } from '../hooks/useFilters';
|
import { useFilters } from '../hooks/useFilters';
|
||||||
|
|
||||||
|
import { FeaturedTags } from './featured_tags';
|
||||||
import { AccountFilters } from './filters';
|
import { AccountFilters } from './filters';
|
||||||
|
|
||||||
const emptyList = ImmutableList<string>();
|
const emptyList = ImmutableList<string>();
|
||||||
@ -135,6 +136,7 @@ const Prepend: FC<{
|
|||||||
<>
|
<>
|
||||||
<AccountHeader accountId={accountId} hideTabs />
|
<AccountHeader accountId={accountId} hideTabs />
|
||||||
<AccountFilters />
|
<AccountFilters />
|
||||||
|
<FeaturedTags accountId={accountId} />
|
||||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -35,3 +35,25 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tagsWrapper {
|
||||||
|
margin: 0 24px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsList {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsListShowAll {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|||||||
272
app/javascript/mastodon/features/collections/editor.tsx
Normal file
272
app/javascript/mastodon/features/collections/editor.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { useParams, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||||
|
import type {
|
||||||
|
ApiCollectionJSON,
|
||||||
|
ApiCreateCollectionPayload,
|
||||||
|
ApiUpdateCollectionPayload,
|
||||||
|
} from 'mastodon/api_types/collections';
|
||||||
|
import { Button } from 'mastodon/components/button';
|
||||||
|
import { Column } from 'mastodon/components/column';
|
||||||
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { TextAreaField, ToggleField } from 'mastodon/components/form_fields';
|
||||||
|
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
import {
|
||||||
|
createCollection,
|
||||||
|
fetchCollection,
|
||||||
|
updateCollection,
|
||||||
|
} from 'mastodon/reducers/slices/collections';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' },
|
||||||
|
create: {
|
||||||
|
id: 'column.create_collection',
|
||||||
|
defaultMessage: 'Create collection',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const CollectionSettings: React.FC<{
|
||||||
|
collection?: ApiCollectionJSON | null;
|
||||||
|
}> = ({ collection }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name: initialName = '',
|
||||||
|
description: initialDescription = '',
|
||||||
|
tag,
|
||||||
|
discoverable: initialDiscoverable = true,
|
||||||
|
sensitive: initialSensitive = false,
|
||||||
|
} = collection ?? {};
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [description, setDescription] = useState(initialDescription);
|
||||||
|
const [topic, setTopic] = useState(tag?.name ?? '');
|
||||||
|
const [discoverable] = useState(initialDiscoverable);
|
||||||
|
const [sensitive, setSensitive] = useState(initialSensitive);
|
||||||
|
|
||||||
|
const handleNameChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setDescription(event.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTopicChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTopic(event.target.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSensitiveChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSensitive(event.target.checked);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const payload: ApiUpdateCollectionPayload = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tag_name: topic,
|
||||||
|
discoverable,
|
||||||
|
sensitive,
|
||||||
|
};
|
||||||
|
|
||||||
|
void dispatch(updateCollection({ payload })).then(() => {
|
||||||
|
history.push(`/collections`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const payload: ApiCreateCollectionPayload = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
discoverable,
|
||||||
|
sensitive,
|
||||||
|
};
|
||||||
|
if (topic) {
|
||||||
|
payload.tag_name = topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispatch(
|
||||||
|
createCollection({
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
).then((result) => {
|
||||||
|
if (isFulfilled(result)) {
|
||||||
|
history.replace(
|
||||||
|
`/collections/${result.payload.collection.id}/edit`,
|
||||||
|
);
|
||||||
|
history.push(`/collections`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, dispatch, name, description, topic, discoverable, sensitive, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||||
|
<div className='fields-group'>
|
||||||
|
<TextInputField
|
||||||
|
required
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.collection_name'
|
||||||
|
defaultMessage='Name'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.name_length_hint'
|
||||||
|
defaultMessage='40 characters limit'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={name}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='fields-group'>
|
||||||
|
<TextAreaField
|
||||||
|
required
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.collection_description'
|
||||||
|
defaultMessage='Description'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.description_length_hint'
|
||||||
|
defaultMessage='100 characters limit'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={description}
|
||||||
|
onChange={handleDescriptionChange}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='fields-group'>
|
||||||
|
<TextInputField
|
||||||
|
required={false}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.collection_topic'
|
||||||
|
defaultMessage='Topic'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.topic_hint'
|
||||||
|
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={topic}
|
||||||
|
onChange={handleTopicChange}
|
||||||
|
maxLength={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='fields-group'>
|
||||||
|
<ToggleField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.mark_as_sensitive'
|
||||||
|
defaultMessage='Mark as sensitive'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.mark_as_sensitive_hint'
|
||||||
|
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
checked={sensitive}
|
||||||
|
onChange={handleSensitiveChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='actions'>
|
||||||
|
<Button type='submit'>
|
||||||
|
{id ? (
|
||||||
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='lists.create' defaultMessage='Create' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CollectionEditorPage: React.FC<{
|
||||||
|
multiColumn?: boolean;
|
||||||
|
}> = ({ multiColumn }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { id } = useParams<{ id?: string }>();
|
||||||
|
const collection = useAppSelector((state) =>
|
||||||
|
id ? state.collections.collections[id] : undefined,
|
||||||
|
);
|
||||||
|
const isEditMode = !!id;
|
||||||
|
const isLoading = isEditMode && !collection;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
void dispatch(fetchCollection({ collectionId: id }));
|
||||||
|
}
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
const pageTitle = intl.formatMessage(id ? messages.edit : messages.create);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} label={pageTitle}>
|
||||||
|
<ColumnHeader
|
||||||
|
title={pageTitle}
|
||||||
|
icon='list-ul'
|
||||||
|
iconComponent={ListAltIcon}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
showBackButton
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='scrollable'>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
<CollectionSettings collection={collection} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -16,7 +16,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
|
|||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
import {
|
import {
|
||||||
createCollection,
|
|
||||||
fetchAccountCollections,
|
fetchAccountCollections,
|
||||||
selectMyCollections,
|
selectMyCollections,
|
||||||
} from 'mastodon/reducers/slices/collections';
|
} from 'mastodon/reducers/slices/collections';
|
||||||
@ -49,13 +48,14 @@ const ListItem: React.FC<{
|
|||||||
const handleDeleteClick = useCallback(() => {
|
const handleDeleteClick = useCallback(() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal({
|
openModal({
|
||||||
modalType: 'CONFIRM_DELETE_LIST',
|
modalType: 'CONFIRM_DELETE_COLLECTION',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
listId: id,
|
name,
|
||||||
|
id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id, name]);
|
||||||
|
|
||||||
const menu = useMemo(
|
const menu = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -67,7 +67,7 @@ const ListItem: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='lists__item'>
|
<div className='lists__item'>
|
||||||
<Link to={`/collections/${id}`} className='lists__item__title'>
|
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -94,24 +94,6 @@ export const Collections: React.FC<{
|
|||||||
void dispatch(fetchAccountCollections({ accountId: me }));
|
void dispatch(fetchAccountCollections({ accountId: me }));
|
||||||
}, [dispatch, me]);
|
}, [dispatch, me]);
|
||||||
|
|
||||||
const addDummyCollection = useCallback(
|
|
||||||
(event: React.MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
void dispatch(
|
|
||||||
createCollection({
|
|
||||||
payload: {
|
|
||||||
name: 'Test Collection',
|
|
||||||
description: 'A useful test collection',
|
|
||||||
discoverable: true,
|
|
||||||
sensitive: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const emptyMessage =
|
const emptyMessage =
|
||||||
status === 'error' ? (
|
status === 'error' ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
@ -152,7 +134,6 @@ export const Collections: React.FC<{
|
|||||||
className='column-header__button'
|
className='column-header__button'
|
||||||
title={intl.formatMessage(messages.create)}
|
title={intl.formatMessage(messages.create)}
|
||||||
aria-label={intl.formatMessage(messages.create)}
|
aria-label={intl.formatMessage(messages.create)}
|
||||||
onClick={addDummyCollection}
|
|
||||||
>
|
>
|
||||||
<Icon id='plus' icon={AddIcon} />
|
<Icon id='plus' icon={AddIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
|
|
||||||
|
import { deleteCollection } from 'mastodon/reducers/slices/collections';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||||
|
import { ConfirmationModal } from './confirmation_modal';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
deleteListTitle: {
|
||||||
|
id: 'confirmations.delete_collection.title',
|
||||||
|
defaultMessage: 'Delete "{name}"?',
|
||||||
|
},
|
||||||
|
deleteListMessage: {
|
||||||
|
id: 'confirmations.delete_collection.message',
|
||||||
|
defaultMessage: 'This action cannot be undone.',
|
||||||
|
},
|
||||||
|
deleteListConfirm: {
|
||||||
|
id: 'confirmations.delete_collection.confirm',
|
||||||
|
defaultMessage: 'Delete',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfirmDeleteCollectionModal: React.FC<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} & BaseConfirmationModalProps
|
||||||
|
> = ({ id, name, onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const onConfirm = useCallback(() => {
|
||||||
|
void dispatch(deleteCollection({ collectionId: id }));
|
||||||
|
history.push('/collections');
|
||||||
|
}, [dispatch, history, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
title={intl.formatMessage(messages.deleteListTitle, {
|
||||||
|
name,
|
||||||
|
})}
|
||||||
|
message={intl.formatMessage(messages.deleteListMessage)}
|
||||||
|
confirm={intl.formatMessage(messages.deleteListConfirm)}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
export { ConfirmationModal } from './confirmation_modal';
|
export { ConfirmationModal } from './confirmation_modal';
|
||||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||||
export { ConfirmDeleteListModal } from './delete_list';
|
export { ConfirmDeleteListModal } from './delete_list';
|
||||||
|
export { ConfirmDeleteCollectionModal } from './delete_collection';
|
||||||
export {
|
export {
|
||||||
ConfirmReplyModal,
|
ConfirmReplyModal,
|
||||||
ConfirmEditStatusModal,
|
ConfirmEditStatusModal,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
ConfirmationModal,
|
ConfirmationModal,
|
||||||
ConfirmDeleteStatusModal,
|
ConfirmDeleteStatusModal,
|
||||||
ConfirmDeleteListModal,
|
ConfirmDeleteListModal,
|
||||||
|
ConfirmDeleteCollectionModal,
|
||||||
ConfirmReplyModal,
|
ConfirmReplyModal,
|
||||||
ConfirmEditStatusModal,
|
ConfirmEditStatusModal,
|
||||||
ConfirmUnblockModal,
|
ConfirmUnblockModal,
|
||||||
@ -57,6 +58,7 @@ export const MODAL_COMPONENTS = {
|
|||||||
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
||||||
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
|
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
|
||||||
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
||||||
|
'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }),
|
||||||
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
||||||
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
||||||
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
|
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
|
||||||
|
|||||||
@ -64,6 +64,7 @@ import {
|
|||||||
ListEdit,
|
ListEdit,
|
||||||
ListMembers,
|
ListMembers,
|
||||||
Collections,
|
Collections,
|
||||||
|
CollectionsEditor,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
Mutes,
|
Mutes,
|
||||||
@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||||
|
{areCollectionsEnabled() &&
|
||||||
|
<WrappedRoute path='/collections/new' component={CollectionsEditor} content={children} />
|
||||||
|
}
|
||||||
|
{areCollectionsEnabled() &&
|
||||||
|
<WrappedRoute path='/collections/:id/edit' component={CollectionsEditor} content={children} />
|
||||||
|
}
|
||||||
{areCollectionsEnabled() &&
|
{areCollectionsEnabled() &&
|
||||||
<WrappedRoute path='/collections' component={Collections} content={children} />
|
<WrappedRoute path='/collections' component={Collections} content={children} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,12 @@ export function Collections () {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CollectionsEditor () {
|
||||||
|
return import('../../collections/editor').then(
|
||||||
|
module => ({default: module.CollectionEditorPage})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Status () {
|
export function Status () {
|
||||||
return import('../../status');
|
return import('../../status');
|
||||||
}
|
}
|
||||||
|
|||||||
172
app/javascript/mastodon/hooks/useOverflow.ts
Normal file
172
app/javascript/mastodon/hooks/useOverflow.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate and manage overflow of child elements within a container.
|
||||||
|
*
|
||||||
|
* To use, wire up the `wrapperRef` to the container element, and the `listRef` to the
|
||||||
|
* child element that contains the items to be measured. If autoResize is true,
|
||||||
|
* the list element will have its max-width set to prevent wrapping. The listRef element
|
||||||
|
* requires both position:relative and overflow:hidden styles to work correctly.
|
||||||
|
*/
|
||||||
|
export function useOverflow({
|
||||||
|
autoResize,
|
||||||
|
padding = 4,
|
||||||
|
}: { autoResize?: boolean; padding?: number } = {}) {
|
||||||
|
const [hiddenIndex, setHiddenIndex] = useState(-1);
|
||||||
|
const [hiddenCount, setHiddenCount] = useState(0);
|
||||||
|
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
|
||||||
|
|
||||||
|
// This is the item container element.
|
||||||
|
const listRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// The main recalculation function.
|
||||||
|
const handleRecalculate = useCallback(() => {
|
||||||
|
const listEle = listRef.current;
|
||||||
|
if (!listEle) return;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setHiddenIndex(-1);
|
||||||
|
setHiddenCount(0);
|
||||||
|
setMaxWidth('none');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the width via the parent element, minus the more button, minus the padding.
|
||||||
|
const maxWidth =
|
||||||
|
(listEle.parentElement?.offsetWidth ?? 0) -
|
||||||
|
(listEle.nextElementSibling?.scrollWidth ?? 0) -
|
||||||
|
padding;
|
||||||
|
if (maxWidth <= 0) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through children until we exceed max width.
|
||||||
|
let visible = 0;
|
||||||
|
let index = 0;
|
||||||
|
let totalWidth = 0;
|
||||||
|
for (const child of listEle.children) {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
const rightOffset = child.offsetLeft + child.offsetWidth;
|
||||||
|
if (rightOffset <= maxWidth) {
|
||||||
|
visible += 1;
|
||||||
|
totalWidth = rightOffset;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All are visible, so remove max-width restriction.
|
||||||
|
if (visible === listEle.children.length) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the width to avoid wrapping, and set hidden count.
|
||||||
|
setHiddenIndex(index);
|
||||||
|
setHiddenCount(listEle.children.length - visible);
|
||||||
|
setMaxWidth(totalWidth);
|
||||||
|
}, [padding]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current && autoResize) {
|
||||||
|
listRef.current.style.maxWidth =
|
||||||
|
typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
|
||||||
|
}
|
||||||
|
}, [autoResize, maxWidth]);
|
||||||
|
|
||||||
|
// Set up observers to watch for size and content changes.
|
||||||
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
|
const mutationObserverRef = useRef<MutationObserver | null>(null);
|
||||||
|
|
||||||
|
// Helper to get or create the resize observer.
|
||||||
|
const resizeObserver = useCallback(() => {
|
||||||
|
const observer = (resizeObserverRef.current ??= new ResizeObserver(
|
||||||
|
handleRecalculate,
|
||||||
|
));
|
||||||
|
return observer;
|
||||||
|
}, [handleRecalculate]);
|
||||||
|
|
||||||
|
// Iterate through children and observe them for size changes.
|
||||||
|
const handleChildrenChange = useCallback(() => {
|
||||||
|
const listEle = listRef.current;
|
||||||
|
const observer = resizeObserver();
|
||||||
|
|
||||||
|
if (listEle) {
|
||||||
|
for (const child of listEle.children) {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
observer.observe(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleRecalculate();
|
||||||
|
}, [handleRecalculate, resizeObserver]);
|
||||||
|
|
||||||
|
// Helper to get or create the mutation observer.
|
||||||
|
const mutationObserver = useCallback(() => {
|
||||||
|
const observer = (mutationObserverRef.current ??= new MutationObserver(
|
||||||
|
handleChildrenChange,
|
||||||
|
));
|
||||||
|
return observer;
|
||||||
|
}, [handleChildrenChange]);
|
||||||
|
|
||||||
|
// Set up observers.
|
||||||
|
const handleObserve = useCallback(() => {
|
||||||
|
if (wrapperRef.current) {
|
||||||
|
resizeObserver().observe(wrapperRef.current);
|
||||||
|
}
|
||||||
|
if (listRef.current) {
|
||||||
|
mutationObserver().observe(listRef.current, { childList: true });
|
||||||
|
handleChildrenChange();
|
||||||
|
}
|
||||||
|
}, [handleChildrenChange, mutationObserver, resizeObserver]);
|
||||||
|
|
||||||
|
// Watch the wrapper for size changes, and recalculate when it resizes.
|
||||||
|
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||||
|
const wrapperRefCallback = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
if (node) {
|
||||||
|
wrapperRef.current = node;
|
||||||
|
handleObserve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleObserve],
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are changes to the children, recalculate which are visible.
|
||||||
|
const listRefCallback = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
if (node) {
|
||||||
|
listRef.current = node;
|
||||||
|
handleObserve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleObserve],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleObserve();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserverRef.current) {
|
||||||
|
resizeObserverRef.current.disconnect();
|
||||||
|
resizeObserverRef.current = null;
|
||||||
|
}
|
||||||
|
if (mutationObserverRef.current) {
|
||||||
|
mutationObserverRef.current.disconnect();
|
||||||
|
mutationObserverRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handleObserve]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hiddenCount,
|
||||||
|
hasOverflow: hiddenCount > 0,
|
||||||
|
wrapperRef: wrapperRefCallback,
|
||||||
|
hiddenIndex,
|
||||||
|
maxWidth,
|
||||||
|
listRef: listRefCallback,
|
||||||
|
recalculate: handleRecalculate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "Αποκεντρωμένα μέσα κοινωνικής δικτύωσης που βασίζονται στο {mastodon}",
|
"about.powered_by": "Αποκεντρωμένα μέσα κοινωνικής δικτύωσης που βασίζονται στο {mastodon}",
|
||||||
"about.rules": "Κανόνες διακομιστή",
|
"about.rules": "Κανόνες διακομιστή",
|
||||||
"account.account_note_header": "Προσωπική σημείωση",
|
"account.account_note_header": "Προσωπική σημείωση",
|
||||||
|
"account.activity": "Δραστηριότητα",
|
||||||
"account.add_note": "Προσθέστε μια προσωπική σημείωση",
|
"account.add_note": "Προσθέστε μια προσωπική σημείωση",
|
||||||
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
|
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
|
||||||
"account.badges.bot": "Αυτοματοποιημένος",
|
"account.badges.bot": "Αυτοματοποιημένος",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Ετικέτες",
|
"account.featured.hashtags": "Ετικέτες",
|
||||||
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
|
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
|
||||||
"account.featured_tags.last_status_never": "Καμία ανάρτηση",
|
"account.featured_tags.last_status_never": "Καμία ανάρτηση",
|
||||||
|
"account.filters.all": "Όλη η δραστηριότητα",
|
||||||
|
"account.filters.boosts_toggle": "Εμφάνιση ενισχύσεων",
|
||||||
|
"account.filters.posts_boosts": "Αναρτήσεις και ενισχύσεις",
|
||||||
|
"account.filters.posts_only": "Αναρτήσεις",
|
||||||
|
"account.filters.posts_replies": "Αναρτήσεις και απαντήσεις",
|
||||||
|
"account.filters.replies_toggle": "Εμφάνιση απαντήσεων",
|
||||||
"account.follow": "Ακολούθησε",
|
"account.follow": "Ακολούθησε",
|
||||||
"account.follow_back": "Ακολούθησε και εσύ",
|
"account.follow_back": "Ακολούθησε και εσύ",
|
||||||
"account.follow_back_short": "Ακολούθησε και εσύ",
|
"account.follow_back_short": "Ακολούθησε και εσύ",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Ειδοποιήσεις",
|
"tabs_bar.notifications": "Ειδοποιήσεις",
|
||||||
"tabs_bar.publish": "Νέα Ανάρτηση",
|
"tabs_bar.publish": "Νέα Ανάρτηση",
|
||||||
"tabs_bar.search": "Αναζήτηση",
|
"tabs_bar.search": "Αναζήτηση",
|
||||||
|
"tag.remove": "Αφαίρεση",
|
||||||
"terms_of_service.effective_as_of": "Ενεργό από {date}",
|
"terms_of_service.effective_as_of": "Ενεργό από {date}",
|
||||||
"terms_of_service.title": "Όροι Παροχής Υπηρεσιών",
|
"terms_of_service.title": "Όροι Παροχής Υπηρεσιών",
|
||||||
"terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}",
|
"terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}",
|
||||||
|
|||||||
@ -212,21 +212,31 @@
|
|||||||
"closed_registrations_modal.find_another_server": "Find another server",
|
"closed_registrations_modal.find_another_server": "Find another server",
|
||||||
"closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!",
|
"closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!",
|
||||||
"closed_registrations_modal.title": "Signing up on Mastodon",
|
"closed_registrations_modal.title": "Signing up on Mastodon",
|
||||||
|
"collections.collection_description": "Description",
|
||||||
|
"collections.collection_name": "Name",
|
||||||
|
"collections.collection_topic": "Topic",
|
||||||
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
|
"collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.",
|
||||||
"collections.create_collection": "Create collection",
|
"collections.create_collection": "Create collection",
|
||||||
"collections.delete_collection": "Delete collection",
|
"collections.delete_collection": "Delete collection",
|
||||||
|
"collections.description_length_hint": "100 characters limit",
|
||||||
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
"collections.error_loading_collections": "There was an error when trying to load your collections.",
|
||||||
|
"collections.mark_as_sensitive": "Mark as sensitive",
|
||||||
|
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
|
||||||
|
"collections.name_length_hint": "100 characters limit",
|
||||||
"collections.no_collections_yet": "No collections yet.",
|
"collections.no_collections_yet": "No collections yet.",
|
||||||
|
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||||
"collections.view_collection": "View collection",
|
"collections.view_collection": "View collection",
|
||||||
"column.about": "About",
|
"column.about": "About",
|
||||||
"column.blocks": "Blocked users",
|
"column.blocks": "Blocked users",
|
||||||
"column.bookmarks": "Bookmarks",
|
"column.bookmarks": "Bookmarks",
|
||||||
"column.collections": "My collections",
|
"column.collections": "My collections",
|
||||||
"column.community": "Local timeline",
|
"column.community": "Local timeline",
|
||||||
|
"column.create_collection": "Create collection",
|
||||||
"column.create_list": "Create list",
|
"column.create_list": "Create list",
|
||||||
"column.direct": "Private mentions",
|
"column.direct": "Private mentions",
|
||||||
"column.directory": "Browse profiles",
|
"column.directory": "Browse profiles",
|
||||||
"column.domain_blocks": "Blocked domains",
|
"column.domain_blocks": "Blocked domains",
|
||||||
|
"column.edit_collection": "Edit collection",
|
||||||
"column.edit_list": "Edit list",
|
"column.edit_list": "Edit list",
|
||||||
"column.favourites": "Favorites",
|
"column.favourites": "Favorites",
|
||||||
"column.firehose": "Live feeds",
|
"column.firehose": "Live feeds",
|
||||||
@ -281,6 +291,9 @@
|
|||||||
"confirmations.delete.confirm": "Delete",
|
"confirmations.delete.confirm": "Delete",
|
||||||
"confirmations.delete.message": "Are you sure you want to delete this post?",
|
"confirmations.delete.message": "Are you sure you want to delete this post?",
|
||||||
"confirmations.delete.title": "Delete post?",
|
"confirmations.delete.title": "Delete post?",
|
||||||
|
"confirmations.delete_collection.confirm": "Delete",
|
||||||
|
"confirmations.delete_collection.message": "This action cannot be undone.",
|
||||||
|
"confirmations.delete_collection.title": "Delete \"{name}\"?",
|
||||||
"confirmations.delete_list.confirm": "Delete",
|
"confirmations.delete_list.confirm": "Delete",
|
||||||
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
||||||
"confirmations.delete_list.title": "Delete list?",
|
"confirmations.delete_list.title": "Delete list?",
|
||||||
@ -427,6 +440,7 @@
|
|||||||
"featured_carousel.current": "<sr>Post</sr> {current, number} / {max, number}",
|
"featured_carousel.current": "<sr>Post</sr> {current, number} / {max, number}",
|
||||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||||
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
||||||
|
"featured_tags.more_items": "+{count}",
|
||||||
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
|
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
|
||||||
"filter_modal.added.context_mismatch_title": "Context mismatch!",
|
"filter_modal.added.context_mismatch_title": "Context mismatch!",
|
||||||
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
||||||
"about.rules": "Reglas del servidor",
|
"about.rules": "Reglas del servidor",
|
||||||
"account.account_note_header": "Nota personal",
|
"account.account_note_header": "Nota personal",
|
||||||
|
"account.activity": "Actividad",
|
||||||
"account.add_note": "Agregar una nota personal",
|
"account.add_note": "Agregar una nota personal",
|
||||||
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
|
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
|
||||||
"account.badges.bot": "Automatizada",
|
"account.badges.bot": "Automatizada",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Etiquetas",
|
"account.featured.hashtags": "Etiquetas",
|
||||||
"account.featured_tags.last_status_at": "Último mensaje: {date}",
|
"account.featured_tags.last_status_at": "Último mensaje: {date}",
|
||||||
"account.featured_tags.last_status_never": "Sin mensajes",
|
"account.featured_tags.last_status_never": "Sin mensajes",
|
||||||
|
"account.filters.all": "Toda la actividad",
|
||||||
|
"account.filters.boosts_toggle": "Mostrar adhesiones",
|
||||||
|
"account.filters.posts_boosts": "Mensajes y adhesiones",
|
||||||
|
"account.filters.posts_only": "Mensajes",
|
||||||
|
"account.filters.posts_replies": "Mensajes y respuestas",
|
||||||
|
"account.filters.replies_toggle": "Mostrar respuestas",
|
||||||
"account.follow": "Seguir",
|
"account.follow": "Seguir",
|
||||||
"account.follow_back": "Seguir",
|
"account.follow_back": "Seguir",
|
||||||
"account.follow_back_short": "Seguir",
|
"account.follow_back_short": "Seguir",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Notificaciones",
|
"tabs_bar.notifications": "Notificaciones",
|
||||||
"tabs_bar.publish": "Nuevo mensaje",
|
"tabs_bar.publish": "Nuevo mensaje",
|
||||||
"tabs_bar.search": "Buscar",
|
"tabs_bar.search": "Buscar",
|
||||||
|
"tag.remove": "Quitar",
|
||||||
"terms_of_service.effective_as_of": "Efectivo a partir de {date}",
|
"terms_of_service.effective_as_of": "Efectivo a partir de {date}",
|
||||||
"terms_of_service.title": "Términos del servicio",
|
"terms_of_service.title": "Términos del servicio",
|
||||||
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "Medio social descentralizado con tecnología de {mastodon}",
|
"about.powered_by": "Medio social descentralizado con tecnología de {mastodon}",
|
||||||
"about.rules": "Reglas del servidor",
|
"about.rules": "Reglas del servidor",
|
||||||
"account.account_note_header": "Nota personal",
|
"account.account_note_header": "Nota personal",
|
||||||
|
"account.activity": "Actividad",
|
||||||
"account.add_note": "Añadir una nota personal",
|
"account.add_note": "Añadir una nota personal",
|
||||||
"account.add_or_remove_from_list": "Agregar o eliminar de las listas",
|
"account.add_or_remove_from_list": "Agregar o eliminar de las listas",
|
||||||
"account.badges.bot": "Automatizada",
|
"account.badges.bot": "Automatizada",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Etiquetas",
|
"account.featured.hashtags": "Etiquetas",
|
||||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||||
"account.featured_tags.last_status_never": "Sin publicaciones",
|
"account.featured_tags.last_status_never": "Sin publicaciones",
|
||||||
|
"account.filters.all": "Toda la actividad",
|
||||||
|
"account.filters.boosts_toggle": "Mostrar impulsos",
|
||||||
|
"account.filters.posts_boosts": "Publicaciones e impulsos",
|
||||||
|
"account.filters.posts_only": "Publicaciones",
|
||||||
|
"account.filters.posts_replies": "Publicaciones y respuestas",
|
||||||
|
"account.filters.replies_toggle": "Mostrar respuestas",
|
||||||
"account.follow": "Seguir",
|
"account.follow": "Seguir",
|
||||||
"account.follow_back": "Seguir también",
|
"account.follow_back": "Seguir también",
|
||||||
"account.follow_back_short": "Seguir también",
|
"account.follow_back_short": "Seguir también",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Notificaciones",
|
"tabs_bar.notifications": "Notificaciones",
|
||||||
"tabs_bar.publish": "Nueva publicación",
|
"tabs_bar.publish": "Nueva publicación",
|
||||||
"tabs_bar.search": "Buscar",
|
"tabs_bar.search": "Buscar",
|
||||||
|
"tag.remove": "Eliminar",
|
||||||
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
||||||
"terms_of_service.title": "Condiciones del servicio",
|
"terms_of_service.title": "Condiciones del servicio",
|
||||||
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
||||||
"about.rules": "Reglas del servidor",
|
"about.rules": "Reglas del servidor",
|
||||||
"account.account_note_header": "Nota personal",
|
"account.account_note_header": "Nota personal",
|
||||||
|
"account.activity": "Actividad",
|
||||||
|
"account.add_note": "Añadir una nota personal",
|
||||||
"account.add_or_remove_from_list": "Agregar o eliminar de listas",
|
"account.add_or_remove_from_list": "Agregar o eliminar de listas",
|
||||||
"account.badges.bot": "Automatizada",
|
"account.badges.bot": "Automatizada",
|
||||||
"account.badges.group": "Grupo",
|
"account.badges.group": "Grupo",
|
||||||
@ -27,6 +29,7 @@
|
|||||||
"account.direct": "Mención privada a @{name}",
|
"account.direct": "Mención privada a @{name}",
|
||||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo",
|
"account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo",
|
||||||
"account.domain_blocking": "Bloqueando dominio",
|
"account.domain_blocking": "Bloqueando dominio",
|
||||||
|
"account.edit_note": "Eidtar nota personal",
|
||||||
"account.edit_profile": "Editar perfil",
|
"account.edit_profile": "Editar perfil",
|
||||||
"account.edit_profile_short": "Editar",
|
"account.edit_profile_short": "Editar",
|
||||||
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
||||||
@ -39,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Etiquetas",
|
"account.featured.hashtags": "Etiquetas",
|
||||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||||
"account.featured_tags.last_status_never": "Sin publicaciones",
|
"account.featured_tags.last_status_never": "Sin publicaciones",
|
||||||
|
"account.filters.all": "Toda la actividad",
|
||||||
|
"account.filters.boosts_toggle": "Mostrar impulsos",
|
||||||
|
"account.filters.posts_boosts": "Publicaciones e impulsos",
|
||||||
|
"account.filters.posts_only": "Publicaciones",
|
||||||
|
"account.filters.posts_replies": "Publicaciones y respuestas",
|
||||||
|
"account.filters.replies_toggle": "Mostrar respuestas",
|
||||||
"account.follow": "Seguir",
|
"account.follow": "Seguir",
|
||||||
"account.follow_back": "Seguir también",
|
"account.follow_back": "Seguir también",
|
||||||
"account.follow_back_short": "Seguir también",
|
"account.follow_back_short": "Seguir también",
|
||||||
@ -72,6 +81,14 @@
|
|||||||
"account.muting": "Silenciando",
|
"account.muting": "Silenciando",
|
||||||
"account.mutual": "Os seguís mutuamente",
|
"account.mutual": "Os seguís mutuamente",
|
||||||
"account.no_bio": "Sin biografía.",
|
"account.no_bio": "Sin biografía.",
|
||||||
|
"account.node_modal.callout": "Las notas personales solo son visibles para ti.",
|
||||||
|
"account.node_modal.edit_title": "Editar nota personal",
|
||||||
|
"account.node_modal.error_unknown": "No se pudo guardar la nota",
|
||||||
|
"account.node_modal.field_label": "Nota personal",
|
||||||
|
"account.node_modal.save": "Guardar",
|
||||||
|
"account.node_modal.title": "Añadir una nota personal",
|
||||||
|
"account.note.edit_button": "Editar",
|
||||||
|
"account.note.title": "Nota personal (visible solo para ti)",
|
||||||
"account.open_original_page": "Abrir página original",
|
"account.open_original_page": "Abrir página original",
|
||||||
"account.posts": "Publicaciones",
|
"account.posts": "Publicaciones",
|
||||||
"account.posts_with_replies": "Publicaciones y respuestas",
|
"account.posts_with_replies": "Publicaciones y respuestas",
|
||||||
@ -1020,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Notificaciones",
|
"tabs_bar.notifications": "Notificaciones",
|
||||||
"tabs_bar.publish": "Nueva Publicación",
|
"tabs_bar.publish": "Nueva Publicación",
|
||||||
"tabs_bar.search": "Buscar",
|
"tabs_bar.search": "Buscar",
|
||||||
|
"tag.remove": "Eliminar",
|
||||||
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
||||||
"terms_of_service.title": "Términos del servicio",
|
"terms_of_service.title": "Términos del servicio",
|
||||||
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
"terms_of_service.upcoming_changes_on": "Próximos cambios el {date}",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "Comunicación social descentralizada grazas a {mastodon}",
|
"about.powered_by": "Comunicación social descentralizada grazas a {mastodon}",
|
||||||
"about.rules": "Regras do servidor",
|
"about.rules": "Regras do servidor",
|
||||||
"account.account_note_header": "Nota persoal",
|
"account.account_note_header": "Nota persoal",
|
||||||
|
"account.activity": "Actividade",
|
||||||
"account.add_note": "Engadir nota persoal",
|
"account.add_note": "Engadir nota persoal",
|
||||||
"account.add_or_remove_from_list": "Engadir ou eliminar das listaxes",
|
"account.add_or_remove_from_list": "Engadir ou eliminar das listaxes",
|
||||||
"account.badges.bot": "Automatizada",
|
"account.badges.bot": "Automatizada",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Cancelos",
|
"account.featured.hashtags": "Cancelos",
|
||||||
"account.featured_tags.last_status_at": "Última publicación o {date}",
|
"account.featured_tags.last_status_at": "Última publicación o {date}",
|
||||||
"account.featured_tags.last_status_never": "Sen publicacións",
|
"account.featured_tags.last_status_never": "Sen publicacións",
|
||||||
|
"account.filters.all": "Toda actividade",
|
||||||
|
"account.filters.boosts_toggle": "Mostrar promocións",
|
||||||
|
"account.filters.posts_boosts": "Publicacións e promocións",
|
||||||
|
"account.filters.posts_only": "Publicacións",
|
||||||
|
"account.filters.posts_replies": "Publicacións e respostas",
|
||||||
|
"account.filters.replies_toggle": "Mostrar respostas",
|
||||||
"account.follow": "Seguir",
|
"account.follow": "Seguir",
|
||||||
"account.follow_back": "Seguir tamén",
|
"account.follow_back": "Seguir tamén",
|
||||||
"account.follow_back_short": "Seguir tamén",
|
"account.follow_back_short": "Seguir tamén",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Notificacións",
|
"tabs_bar.notifications": "Notificacións",
|
||||||
"tabs_bar.publish": "Nova publicación",
|
"tabs_bar.publish": "Nova publicación",
|
||||||
"tabs_bar.search": "Buscar",
|
"tabs_bar.search": "Buscar",
|
||||||
|
"tag.remove": "Retirar",
|
||||||
"terms_of_service.effective_as_of": "Con efecto desde o {date}",
|
"terms_of_service.effective_as_of": "Con efecto desde o {date}",
|
||||||
"terms_of_service.title": "Condicións do Servizo",
|
"terms_of_service.title": "Condicións do Servizo",
|
||||||
"terms_of_service.upcoming_changes_on": "Cambios por vir o {date}",
|
"terms_of_service.upcoming_changes_on": "Cambios por vir o {date}",
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
"about.powered_by": "Desentraliserte sosiale medium drive av {mastodon}",
|
"about.powered_by": "Desentraliserte sosiale medium drive av {mastodon}",
|
||||||
"about.rules": "Tenarreglar",
|
"about.rules": "Tenarreglar",
|
||||||
"account.account_note_header": "Personleg notat",
|
"account.account_note_header": "Personleg notat",
|
||||||
|
"account.activity": "Aktivitet",
|
||||||
|
"account.add_note": "Legg til eit personleg notat",
|
||||||
"account.add_or_remove_from_list": "Legg til eller fjern frå lister",
|
"account.add_or_remove_from_list": "Legg til eller fjern frå lister",
|
||||||
"account.badges.bot": "Robot",
|
"account.badges.bot": "Robot",
|
||||||
"account.badges.group": "Gruppe",
|
"account.badges.group": "Gruppe",
|
||||||
@ -27,6 +29,7 @@
|
|||||||
"account.direct": "Nemn @{name} privat",
|
"account.direct": "Nemn @{name} privat",
|
||||||
"account.disable_notifications": "Slutt å varsle meg når @{name} skriv innlegg",
|
"account.disable_notifications": "Slutt å varsle meg når @{name} skriv innlegg",
|
||||||
"account.domain_blocking": "Blokkerer domenet",
|
"account.domain_blocking": "Blokkerer domenet",
|
||||||
|
"account.edit_note": "Rediger det personlege notatet",
|
||||||
"account.edit_profile": "Rediger profil",
|
"account.edit_profile": "Rediger profil",
|
||||||
"account.edit_profile_short": "Rediger",
|
"account.edit_profile_short": "Rediger",
|
||||||
"account.enable_notifications": "Varsle meg når @{name} skriv innlegg",
|
"account.enable_notifications": "Varsle meg når @{name} skriv innlegg",
|
||||||
@ -39,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "Emneknaggar",
|
"account.featured.hashtags": "Emneknaggar",
|
||||||
"account.featured_tags.last_status_at": "Sist nytta {date}",
|
"account.featured_tags.last_status_at": "Sist nytta {date}",
|
||||||
"account.featured_tags.last_status_never": "Ingen innlegg",
|
"account.featured_tags.last_status_never": "Ingen innlegg",
|
||||||
|
"account.filters.all": "All aktivitet",
|
||||||
|
"account.filters.boosts_toggle": "Vis framhevingar",
|
||||||
|
"account.filters.posts_boosts": "Innlegg og framhevingar",
|
||||||
|
"account.filters.posts_only": "Innlegg",
|
||||||
|
"account.filters.posts_replies": "Innlegg og svar",
|
||||||
|
"account.filters.replies_toggle": "Vis svar",
|
||||||
"account.follow": "Fylg",
|
"account.follow": "Fylg",
|
||||||
"account.follow_back": "Fylg tilbake",
|
"account.follow_back": "Fylg tilbake",
|
||||||
"account.follow_back_short": "Fylg tilbake",
|
"account.follow_back_short": "Fylg tilbake",
|
||||||
@ -72,6 +81,14 @@
|
|||||||
"account.muting": "Dempa",
|
"account.muting": "Dempa",
|
||||||
"account.mutual": "De fylgjer kvarandre",
|
"account.mutual": "De fylgjer kvarandre",
|
||||||
"account.no_bio": "Inga skildring er gjeven.",
|
"account.no_bio": "Inga skildring er gjeven.",
|
||||||
|
"account.node_modal.callout": "Berre du kan sjå personlege notat.",
|
||||||
|
"account.node_modal.edit_title": "Rediger det personlege notatet",
|
||||||
|
"account.node_modal.error_unknown": "Klarte ikkje å lagra notatet",
|
||||||
|
"account.node_modal.field_label": "Personleg notat",
|
||||||
|
"account.node_modal.save": "Lagre",
|
||||||
|
"account.node_modal.title": "Legg til eit personleg notat",
|
||||||
|
"account.note.edit_button": "Rediger",
|
||||||
|
"account.note.title": "Personleg notat (berre synleg for deg)",
|
||||||
"account.open_original_page": "Opne originalsida",
|
"account.open_original_page": "Opne originalsida",
|
||||||
"account.posts": "Tut",
|
"account.posts": "Tut",
|
||||||
"account.posts_with_replies": "Tut og svar",
|
"account.posts_with_replies": "Tut og svar",
|
||||||
@ -187,6 +204,7 @@
|
|||||||
"bundle_modal_error.close": "Lat att",
|
"bundle_modal_error.close": "Lat att",
|
||||||
"bundle_modal_error.message": "Noko gjekk gale då denne sida vart lasta.",
|
"bundle_modal_error.message": "Noko gjekk gale då denne sida vart lasta.",
|
||||||
"bundle_modal_error.retry": "Prøv igjen",
|
"bundle_modal_error.retry": "Prøv igjen",
|
||||||
|
"callout.dismiss": "Avvis",
|
||||||
"carousel.current": "<sr>Side</sr> {current, number} / {max, number}",
|
"carousel.current": "<sr>Side</sr> {current, number} / {max, number}",
|
||||||
"carousel.slide": "Side {current, number} av {max, number}",
|
"carousel.slide": "Side {current, number} av {max, number}",
|
||||||
"closed_registrations.other_server_instructions": "Sidan Mastodon er desentralisert kan du lage ein brukar på ein anna tenar og framleis interagere med denne.",
|
"closed_registrations.other_server_instructions": "Sidan Mastodon er desentralisert kan du lage ein brukar på ein anna tenar og framleis interagere med denne.",
|
||||||
@ -194,9 +212,16 @@
|
|||||||
"closed_registrations_modal.find_another_server": "Finn ein annan tenar",
|
"closed_registrations_modal.find_another_server": "Finn ein annan tenar",
|
||||||
"closed_registrations_modal.preamble": "Mastodon er desentralisert, så uansett kvar du opprettar ein konto, vil du kunne fylgje og samhandle med alle på denne tenaren. Du kan til og med ha din eigen tenar!",
|
"closed_registrations_modal.preamble": "Mastodon er desentralisert, så uansett kvar du opprettar ein konto, vil du kunne fylgje og samhandle med alle på denne tenaren. Du kan til og med ha din eigen tenar!",
|
||||||
"closed_registrations_modal.title": "Registrer deg på Mastodon",
|
"closed_registrations_modal.title": "Registrer deg på Mastodon",
|
||||||
|
"collections.create_a_collection_hint": "Lag ei samling for å tilrå eller dela favorittbrukarkontoane dine med andre.",
|
||||||
|
"collections.create_collection": "Lag ei samling",
|
||||||
|
"collections.delete_collection": "Slett samlinga",
|
||||||
|
"collections.error_loading_collections": "Noko gjekk gale då me prøvde å henta samlingane dine.",
|
||||||
|
"collections.no_collections_yet": "Du har ingen samlingar enno.",
|
||||||
|
"collections.view_collection": "Sjå samlinga",
|
||||||
"column.about": "Om",
|
"column.about": "Om",
|
||||||
"column.blocks": "Blokkerte brukarar",
|
"column.blocks": "Blokkerte brukarar",
|
||||||
"column.bookmarks": "Bokmerke",
|
"column.bookmarks": "Bokmerke",
|
||||||
|
"column.collections": "Samlingane mine",
|
||||||
"column.community": "Lokal tidsline",
|
"column.community": "Lokal tidsline",
|
||||||
"column.create_list": "Lag liste",
|
"column.create_list": "Lag liste",
|
||||||
"column.direct": "Private omtaler",
|
"column.direct": "Private omtaler",
|
||||||
@ -454,6 +479,7 @@
|
|||||||
"footer.source_code": "Vis kjeldekode",
|
"footer.source_code": "Vis kjeldekode",
|
||||||
"footer.status": "Status",
|
"footer.status": "Status",
|
||||||
"footer.terms_of_service": "Brukarvilkår",
|
"footer.terms_of_service": "Brukarvilkår",
|
||||||
|
"form_field.optional": "(valfritt)",
|
||||||
"generic.saved": "Lagra",
|
"generic.saved": "Lagra",
|
||||||
"getting_started.heading": "Kom i gang",
|
"getting_started.heading": "Kom i gang",
|
||||||
"hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}",
|
"hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}",
|
||||||
@ -790,6 +816,7 @@
|
|||||||
"privacy.private.short": "Fylgjarar",
|
"privacy.private.short": "Fylgjarar",
|
||||||
"privacy.public.long": "Kven som helst på og av Mastodon",
|
"privacy.public.long": "Kven som helst på og av Mastodon",
|
||||||
"privacy.public.short": "Offentleg",
|
"privacy.public.short": "Offentleg",
|
||||||
|
"privacy.quote.anyone": "{visibility}, det er lov å sitera",
|
||||||
"privacy.quote.disabled": "{visibility}, ingen kan sitera",
|
"privacy.quote.disabled": "{visibility}, ingen kan sitera",
|
||||||
"privacy.quote.limited": "{visibility}, avgrensa sitat",
|
"privacy.quote.limited": "{visibility}, avgrensa sitat",
|
||||||
"privacy.unlisted.additional": "Dette er akkurat som offentleg, bortsett frå at innlegga ikkje dukkar opp i direktestraumar eller emneknaggar, i oppdagingar eller Mastodon-søk, sjølv om du har sagt ja til at kontoen skal vera synleg.",
|
"privacy.unlisted.additional": "Dette er akkurat som offentleg, bortsett frå at innlegga ikkje dukkar opp i direktestraumar eller emneknaggar, i oppdagingar eller Mastodon-søk, sjølv om du har sagt ja til at kontoen skal vera synleg.",
|
||||||
@ -1010,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "Varsel",
|
"tabs_bar.notifications": "Varsel",
|
||||||
"tabs_bar.publish": "Nytt innlegg",
|
"tabs_bar.publish": "Nytt innlegg",
|
||||||
"tabs_bar.search": "Søk",
|
"tabs_bar.search": "Søk",
|
||||||
|
"tag.remove": "Fjern",
|
||||||
"terms_of_service.effective_as_of": "I kraft frå {date}",
|
"terms_of_service.effective_as_of": "I kraft frå {date}",
|
||||||
"terms_of_service.title": "Bruksvilkår",
|
"terms_of_service.title": "Bruksvilkår",
|
||||||
"terms_of_service.upcoming_changes_on": "Komande endringar {date}",
|
"terms_of_service.upcoming_changes_on": "Komande endringar {date}",
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
"about.powered_by": "由 {mastodon} 驱动的去中心化社交媒体",
|
"about.powered_by": "由 {mastodon} 驱动的去中心化社交媒体",
|
||||||
"about.rules": "站点规则",
|
"about.rules": "站点规则",
|
||||||
"account.account_note_header": "个人备注",
|
"account.account_note_header": "个人备注",
|
||||||
|
"account.activity": "活动",
|
||||||
|
"account.add_note": "添加个人备注",
|
||||||
"account.add_or_remove_from_list": "从列表中添加或移除",
|
"account.add_or_remove_from_list": "从列表中添加或移除",
|
||||||
"account.badges.bot": "机器人",
|
"account.badges.bot": "机器人",
|
||||||
"account.badges.group": "群组",
|
"account.badges.group": "群组",
|
||||||
@ -27,6 +29,7 @@
|
|||||||
"account.direct": "私下提及 @{name}",
|
"account.direct": "私下提及 @{name}",
|
||||||
"account.disable_notifications": "当 @{name} 发布嘟文时不要通知我",
|
"account.disable_notifications": "当 @{name} 发布嘟文时不要通知我",
|
||||||
"account.domain_blocking": "正在屏蔽中的域名",
|
"account.domain_blocking": "正在屏蔽中的域名",
|
||||||
|
"account.edit_note": "编辑个人备注",
|
||||||
"account.edit_profile": "修改个人资料",
|
"account.edit_profile": "修改个人资料",
|
||||||
"account.edit_profile_short": "编辑",
|
"account.edit_profile_short": "编辑",
|
||||||
"account.enable_notifications": "当 @{name} 发布嘟文时通知我",
|
"account.enable_notifications": "当 @{name} 发布嘟文时通知我",
|
||||||
@ -39,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "话题",
|
"account.featured.hashtags": "话题",
|
||||||
"account.featured_tags.last_status_at": "上次发言于 {date}",
|
"account.featured_tags.last_status_at": "上次发言于 {date}",
|
||||||
"account.featured_tags.last_status_never": "暂无嘟文",
|
"account.featured_tags.last_status_never": "暂无嘟文",
|
||||||
|
"account.filters.all": "所有活动",
|
||||||
|
"account.filters.boosts_toggle": "显示转嘟",
|
||||||
|
"account.filters.posts_boosts": "嘟文与转嘟",
|
||||||
|
"account.filters.posts_only": "嘟文",
|
||||||
|
"account.filters.posts_replies": "嘟文与回复",
|
||||||
|
"account.filters.replies_toggle": "显示回复",
|
||||||
"account.follow": "关注",
|
"account.follow": "关注",
|
||||||
"account.follow_back": "回关",
|
"account.follow_back": "回关",
|
||||||
"account.follow_back_short": "回关",
|
"account.follow_back_short": "回关",
|
||||||
@ -72,6 +81,14 @@
|
|||||||
"account.muting": "正在静音",
|
"account.muting": "正在静音",
|
||||||
"account.mutual": "你们互相关注",
|
"account.mutual": "你们互相关注",
|
||||||
"account.no_bio": "未提供描述。",
|
"account.no_bio": "未提供描述。",
|
||||||
|
"account.node_modal.callout": "个人备注仅对您个人可见。",
|
||||||
|
"account.node_modal.edit_title": "编辑个人备注",
|
||||||
|
"account.node_modal.error_unknown": "无法保存备注",
|
||||||
|
"account.node_modal.field_label": "个人备注",
|
||||||
|
"account.node_modal.save": "保存",
|
||||||
|
"account.node_modal.title": "添加个人备注",
|
||||||
|
"account.note.edit_button": "编辑",
|
||||||
|
"account.note.title": "个人备注(仅对您可见)",
|
||||||
"account.open_original_page": "打开原始页面",
|
"account.open_original_page": "打开原始页面",
|
||||||
"account.posts": "嘟文",
|
"account.posts": "嘟文",
|
||||||
"account.posts_with_replies": "嘟文和回复",
|
"account.posts_with_replies": "嘟文和回复",
|
||||||
@ -1020,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "通知",
|
"tabs_bar.notifications": "通知",
|
||||||
"tabs_bar.publish": "新嘟文",
|
"tabs_bar.publish": "新嘟文",
|
||||||
"tabs_bar.search": "搜索",
|
"tabs_bar.search": "搜索",
|
||||||
|
"tag.remove": "移除",
|
||||||
"terms_of_service.effective_as_of": "自 {date} 起生效",
|
"terms_of_service.effective_as_of": "自 {date} 起生效",
|
||||||
"terms_of_service.title": "服务条款",
|
"terms_of_service.title": "服务条款",
|
||||||
"terms_of_service.upcoming_changes_on": "{date} 起即将生效的更改",
|
"terms_of_service.upcoming_changes_on": "{date} 起即将生效的更改",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"about.powered_by": "由 {mastodon} 提供之去中心化社群媒體",
|
"about.powered_by": "由 {mastodon} 提供之去中心化社群媒體",
|
||||||
"about.rules": "伺服器規則",
|
"about.rules": "伺服器規則",
|
||||||
"account.account_note_header": "個人備註",
|
"account.account_note_header": "個人備註",
|
||||||
|
"account.activity": "活動",
|
||||||
"account.add_note": "新增個人備註",
|
"account.add_note": "新增個人備註",
|
||||||
"account.add_or_remove_from_list": "自列表中新增或移除",
|
"account.add_or_remove_from_list": "自列表中新增或移除",
|
||||||
"account.badges.bot": "機器人",
|
"account.badges.bot": "機器人",
|
||||||
@ -41,6 +42,12 @@
|
|||||||
"account.featured.hashtags": "主題標籤",
|
"account.featured.hashtags": "主題標籤",
|
||||||
"account.featured_tags.last_status_at": "上次發嘟於 {date}",
|
"account.featured_tags.last_status_at": "上次發嘟於 {date}",
|
||||||
"account.featured_tags.last_status_never": "沒有嘟文",
|
"account.featured_tags.last_status_never": "沒有嘟文",
|
||||||
|
"account.filters.all": "所有活動",
|
||||||
|
"account.filters.boosts_toggle": "顯示轉嘟",
|
||||||
|
"account.filters.posts_boosts": "嘟文與轉嘟",
|
||||||
|
"account.filters.posts_only": "嘟文",
|
||||||
|
"account.filters.posts_replies": "嘟文與回嘟",
|
||||||
|
"account.filters.replies_toggle": "顯示回嘟",
|
||||||
"account.follow": "跟隨",
|
"account.follow": "跟隨",
|
||||||
"account.follow_back": "跟隨回去",
|
"account.follow_back": "跟隨回去",
|
||||||
"account.follow_back_short": "跟隨回去",
|
"account.follow_back_short": "跟隨回去",
|
||||||
@ -1030,6 +1037,7 @@
|
|||||||
"tabs_bar.notifications": "通知",
|
"tabs_bar.notifications": "通知",
|
||||||
"tabs_bar.publish": "新增嘟文",
|
"tabs_bar.publish": "新增嘟文",
|
||||||
"tabs_bar.search": "搜尋",
|
"tabs_bar.search": "搜尋",
|
||||||
|
"tag.remove": "移除",
|
||||||
"terms_of_service.effective_as_of": "{date} 起生效",
|
"terms_of_service.effective_as_of": "{date} 起生效",
|
||||||
"terms_of_service.title": "服務條款",
|
"terms_of_service.title": "服務條款",
|
||||||
"terms_of_service.upcoming_changes_on": "{date} 起即將發生之異動",
|
"terms_of_service.upcoming_changes_on": "{date} 起即將發生之異動",
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { importFetchedAccounts } from '@/mastodon/actions/importer';
|
||||||
import {
|
import {
|
||||||
apiCreateCollection,
|
apiCreateCollection,
|
||||||
apiGetAccountCollections,
|
apiGetAccountCollections,
|
||||||
// apiGetCollection,
|
apiUpdateCollection,
|
||||||
|
apiGetCollection,
|
||||||
|
apiDeleteCollection,
|
||||||
} from '@/mastodon/api/collections';
|
} from '@/mastodon/api/collections';
|
||||||
import type {
|
import type {
|
||||||
ApiCollectionJSON,
|
ApiCollectionJSON,
|
||||||
ApiCreateCollectionPayload,
|
ApiCreateCollectionPayload,
|
||||||
|
ApiUpdateCollectionPayload,
|
||||||
} from '@/mastodon/api_types/collections';
|
} from '@/mastodon/api_types/collections';
|
||||||
import {
|
import {
|
||||||
createAppSelector,
|
createAppSelector,
|
||||||
@ -59,10 +63,11 @@ const collectionSlice = createSlice({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addCase(fetchAccountCollections.fulfilled, (state, actions) => {
|
builder.addCase(fetchAccountCollections.fulfilled, (state, action) => {
|
||||||
const { collections } = actions.payload;
|
const { collections } = action.payload;
|
||||||
|
|
||||||
const collectionsMap: Record<string, ApiCollectionJSON> = {};
|
const collectionsMap: Record<string, ApiCollectionJSON> =
|
||||||
|
state.collections;
|
||||||
const collectionIds: string[] = [];
|
const collectionIds: string[] = [];
|
||||||
|
|
||||||
collections.forEach((collection) => {
|
collections.forEach((collection) => {
|
||||||
@ -72,12 +77,40 @@ const collectionSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.collections = collectionsMap;
|
state.collections = collectionsMap;
|
||||||
state.accountCollections[actions.meta.arg.accountId] = {
|
state.accountCollections[action.meta.arg.accountId] = {
|
||||||
collectionIds,
|
collectionIds,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetching a single collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.addCase(fetchCollection.fulfilled, (state, action) => {
|
||||||
|
const { collection } = action.payload;
|
||||||
|
state.collections[collection.id] = collection;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updating a collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.addCase(updateCollection.fulfilled, (state, action) => {
|
||||||
|
const { collection } = action.payload;
|
||||||
|
state.collections[collection.id] = collection;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
builder.addCase(deleteCollection.fulfilled, (state, action) => {
|
||||||
|
const { collectionId } = action.meta.arg;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
|
delete state.collections[collectionId];
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creating a collection
|
* Creating a collection
|
||||||
*/
|
*/
|
||||||
@ -86,6 +119,7 @@ const collectionSlice = createSlice({
|
|||||||
const { collection } = actions.payload;
|
const { collection } = actions.payload;
|
||||||
|
|
||||||
state.collections[collection.id] = collection;
|
state.collections[collection.id] = collection;
|
||||||
|
|
||||||
if (state.accountCollections[collection.account_id]) {
|
if (state.accountCollections[collection.account_id]) {
|
||||||
state.accountCollections[collection.account_id]?.collectionIds.unshift(
|
state.accountCollections[collection.account_id]?.collectionIds.unshift(
|
||||||
collection.id,
|
collection.id,
|
||||||
@ -105,13 +139,17 @@ export const fetchAccountCollections = createDataLoadingThunk(
|
|||||||
({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId),
|
({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// To be added soon…
|
export const fetchCollection = createDataLoadingThunk(
|
||||||
//
|
`${collectionSlice.name}/fetchCollection`,
|
||||||
// export const fetchCollection = createDataLoadingThunk(
|
({ collectionId }: { collectionId: string }) =>
|
||||||
// `${collectionSlice.name}/fetchCollection`,
|
apiGetCollection(collectionId),
|
||||||
// ({ collectionId }: { collectionId: string }) =>
|
(payload, { dispatch }) => {
|
||||||
// apiGetCollection(collectionId),
|
if (payload.accounts.length > 0) {
|
||||||
// );
|
dispatch(importFetchedAccounts(payload.accounts));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const createCollection = createDataLoadingThunk(
|
export const createCollection = createDataLoadingThunk(
|
||||||
`${collectionSlice.name}/createCollection`,
|
`${collectionSlice.name}/createCollection`,
|
||||||
@ -119,6 +157,18 @@ export const createCollection = createDataLoadingThunk(
|
|||||||
apiCreateCollection(payload),
|
apiCreateCollection(payload),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const updateCollection = createDataLoadingThunk(
|
||||||
|
`${collectionSlice.name}/updateCollection`,
|
||||||
|
({ payload }: { payload: ApiUpdateCollectionPayload }) =>
|
||||||
|
apiUpdateCollection(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteCollection = createDataLoadingThunk(
|
||||||
|
`${collectionSlice.name}/deleteCollection`,
|
||||||
|
({ collectionId }: { collectionId: string }) =>
|
||||||
|
apiDeleteCollection(collectionId),
|
||||||
|
);
|
||||||
|
|
||||||
export const collections = collectionSlice.reducer;
|
export const collections = collectionSlice.reducer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,7 +186,7 @@ export const selectMyCollections = createAppSelector(
|
|||||||
(state) => state.collections.accountCollections,
|
(state) => state.collections.accountCollections,
|
||||||
(state) => state.collections.collections,
|
(state) => state.collections.collections,
|
||||||
],
|
],
|
||||||
(me, collectionsByAccountId, collectionsById) => {
|
(me, collectionsByAccountId, collectionsMap) => {
|
||||||
const myCollectionsQuery = collectionsByAccountId[me];
|
const myCollectionsQuery = collectionsByAccountId[me];
|
||||||
|
|
||||||
if (!myCollectionsQuery) {
|
if (!myCollectionsQuery) {
|
||||||
@ -151,7 +201,7 @@ export const selectMyCollections = createAppSelector(
|
|||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
collections: collectionIds
|
collections: collectionIds
|
||||||
.map((id) => collectionsById[id])
|
.map((id) => collectionsMap[id])
|
||||||
.filter((c) => !!c),
|
.filter((c) => !!c),
|
||||||
} satisfies AccountCollectionQuery;
|
} satisfies AccountCollectionQuery;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import { Record as ImmutableRecord } from 'immutable';
|
import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { accountDefaultValues } from 'mastodon/models/account';
|
import { accountDefaultValues } from 'mastodon/models/account';
|
||||||
import type { Account, AccountShape } from 'mastodon/models/account';
|
import type { Account, AccountShape } from 'mastodon/models/account';
|
||||||
import type { Relationship } from 'mastodon/models/relationship';
|
import type { Relationship } from 'mastodon/models/relationship';
|
||||||
|
import { createAppSelector } from 'mastodon/store';
|
||||||
import type { RootState } from 'mastodon/store';
|
import type { RootState } from 'mastodon/store';
|
||||||
|
|
||||||
|
import type { ApiHashtagJSON } from '../api_types/tags';
|
||||||
|
|
||||||
const getAccountBase = (state: RootState, id: string) =>
|
const getAccountBase = (state: RootState, id: string) =>
|
||||||
state.accounts.get(id, null);
|
state.accounts.get(id, null);
|
||||||
|
|
||||||
@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord<FullAccountShape>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function makeGetAccount() {
|
export function makeGetAccount() {
|
||||||
return createSelector(
|
return createAppSelector(
|
||||||
[getAccountBase, getAccountRelationship, getAccountMoved],
|
[getAccountBase, getAccountRelationship, getAccountMoved],
|
||||||
(base, relationship, moved) => {
|
(base, relationship, moved) => {
|
||||||
if (base === null) {
|
if (base === null) {
|
||||||
@ -47,23 +50,23 @@ export function makeGetAccount() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAccountHidden = createSelector(
|
export const getAccountHidden = createAppSelector(
|
||||||
[
|
[
|
||||||
(state: RootState, id: string) => state.accounts.get(id)?.hidden,
|
(state, id: string) => state.accounts.get(id)?.hidden,
|
||||||
(state: RootState, id: string) =>
|
(state, id: string) =>
|
||||||
state.relationships.get(id)?.following ||
|
state.relationships.get(id)?.following ||
|
||||||
state.relationships.get(id)?.requested,
|
state.relationships.get(id)?.requested,
|
||||||
(state: RootState, id: string) => id === me,
|
(_, id: string) => id === me,
|
||||||
],
|
],
|
||||||
(hidden, followingOrRequested, isSelf) => {
|
(hidden, followingOrRequested, isSelf) => {
|
||||||
return hidden && !(isSelf || followingOrRequested);
|
return hidden && !(isSelf || followingOrRequested);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAccountFamiliarFollowers = createSelector(
|
export const getAccountFamiliarFollowers = createAppSelector(
|
||||||
[
|
[
|
||||||
(state: RootState) => state.accounts,
|
(state) => state.accounts,
|
||||||
(state: RootState, id: string) => state.accounts_familiar_followers[id],
|
(state, id: string) => state.accounts_familiar_followers[id],
|
||||||
],
|
],
|
||||||
(accounts, accounts_familiar_followers) => {
|
(accounts, accounts_familiar_followers) => {
|
||||||
if (!accounts_familiar_followers) return null;
|
if (!accounts_familiar_followers) return null;
|
||||||
@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector(
|
|||||||
.filter((f) => !!f);
|
.filter((f) => !!f);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type TagType = Omit<
|
||||||
|
ApiHashtagJSON,
|
||||||
|
'history' | 'following' | 'featured'
|
||||||
|
> & {
|
||||||
|
accountId: string;
|
||||||
|
statuses_count: number;
|
||||||
|
last_status_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectAccountFeaturedTags = createAppSelector(
|
||||||
|
[(state) => state.user_lists, (_, accountId: string) => accountId],
|
||||||
|
(user_lists, accountId) => {
|
||||||
|
const list = user_lists.getIn(
|
||||||
|
['featured_tags', accountId, 'items'],
|
||||||
|
ImmutableList(),
|
||||||
|
) as ImmutableList<ImmutableMap<string, string | null>>;
|
||||||
|
return list.toArray().map(
|
||||||
|
(tag) =>
|
||||||
|
({
|
||||||
|
id: tag.get('id') as string,
|
||||||
|
name: tag.get('name') as string,
|
||||||
|
url: tag.get('url') as string,
|
||||||
|
accountId: tag.get('accountId') as string,
|
||||||
|
statuses_count: Number.parseInt(
|
||||||
|
tag.get('statuses_count') as string,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
last_status_at: tag.get('last_status_at') as string,
|
||||||
|
}) satisfies TagType,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -22,3 +22,5 @@ export type OmitValueType<T, V> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AnyFunction = (...args: never) => unknown;
|
export type AnyFunction = (...args: never) => unknown;
|
||||||
|
|
||||||
|
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sql_query_string
|
def sql_query_string
|
||||||
<<-SQL.squish
|
<<~SQL.squish
|
||||||
SELECT accounts.domain, count(*) AS value
|
SELECT accounts.domain, count(*) AS value
|
||||||
FROM statuses
|
FROM statuses
|
||||||
INNER JOIN accounts ON accounts.id = statuses.account_id
|
INNER JOIN accounts ON accounts.id = statuses.account_id
|
||||||
|
|||||||
@ -49,7 +49,7 @@ module Status::ThreadingConcern
|
|||||||
end
|
end
|
||||||
|
|
||||||
def ancestor_statuses(limit)
|
def ancestor_statuses(limit)
|
||||||
Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit])
|
Status.find_by_sql([<<~SQL.squish, id: in_reply_to_id, limit: limit])
|
||||||
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
|
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
|
||||||
AS (
|
AS (
|
||||||
SELECT id, in_reply_to_id, ARRAY[id]
|
SELECT id, in_reply_to_id, ARRAY[id]
|
||||||
@ -73,7 +73,7 @@ module Status::ThreadingConcern
|
|||||||
depth += 1 if depth.present?
|
depth += 1 if depth.present?
|
||||||
limit += 1 if limit.present?
|
limit += 1 if limit.present?
|
||||||
|
|
||||||
descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, depth: depth])
|
descendants_with_self = Status.find_by_sql([<<~SQL.squish, id: id, limit: limit, depth: depth])
|
||||||
WITH RECURSIVE search_tree(id, path) AS (
|
WITH RECURSIVE search_tree(id, path) AS (
|
||||||
SELECT id, ARRAY[id]
|
SELECT id, ARRAY[id]
|
||||||
FROM statuses
|
FROM statuses
|
||||||
|
|||||||
@ -214,6 +214,14 @@ class MediaAttachment < ApplicationRecord
|
|||||||
scope :remote, -> { where.not(remote_url: '') }
|
scope :remote, -> { where.not(remote_url: '') }
|
||||||
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
||||||
scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) }
|
scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) }
|
||||||
|
scope :without_local_interaction, lambda {
|
||||||
|
where.not(Favourite.joins(:account).merge(Account.local).where(Favourite.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
.where.not(Bookmark.where(Bookmark.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
.where.not(Status.local.where(Status.arel_table[:in_reply_to_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
.where.not(Status.local.where(Status.arel_table[:reblog_of_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
.where.not(Quote.joins(:status).merge(Status.local).where(Quote.arel_table[:quoted_status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
.where.not(Quote.joins(:quoted_status).merge(Status.local).where(Quote.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
|
||||||
|
}
|
||||||
|
|
||||||
attr_accessor :skip_download
|
attr_accessor :skip_download
|
||||||
|
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class Status < ApplicationRecord
|
|||||||
has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account
|
has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account
|
||||||
has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account
|
has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account
|
||||||
has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account
|
has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account
|
||||||
|
has_many :local_replied, -> { merge(Account.local) }, through: :replies, source: :account
|
||||||
|
|
||||||
has_and_belongs_to_many :tags # rubocop:disable Rails/HasAndBelongsToMany
|
has_and_belongs_to_many :tags # rubocop:disable Rails/HasAndBelongsToMany
|
||||||
|
|
||||||
|
|||||||
@ -84,7 +84,7 @@ class NotifyService < BaseService
|
|||||||
# This queries private mentions from the recipient to the sender up in the thread.
|
# This queries private mentions from the recipient to the sender up in the thread.
|
||||||
# This allows up to 100 messages that do not match in the thread, allowing conversations
|
# This allows up to 100 messages that do not match in the thread, allowing conversations
|
||||||
# involving multiple people.
|
# involving multiple people.
|
||||||
Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100])
|
Status.count_by_sql([<<~SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100])
|
||||||
WITH RECURSIVE ancestors(id, in_reply_to_id, mention_id, path, depth) AS (
|
WITH RECURSIVE ancestors(id, in_reply_to_id, mention_id, path, depth) AS (
|
||||||
SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0
|
SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0
|
||||||
FROM statuses s
|
FROM statuses s
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @announcement, url: admin_announcement_path(@announcement), html: { novalidate: false } do |form|
|
= simple_form_for [:admin, @announcement], html: { novalidate: false } do |form|
|
||||||
= render 'shared/error_messages', object: @announcement
|
= render 'shared/error_messages', object: @announcement
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @announcement, url: admin_announcements_path, html: { novalidate: false } do |form|
|
= simple_form_for [:admin, @announcement], html: { novalidate: false } do |form|
|
||||||
= render 'shared/error_messages', object: @announcement
|
= render 'shared/error_messages', object: @announcement
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f|
|
= simple_form_for [:admin, @custom_emoji] do |f|
|
||||||
= render 'shared/error_messages', object: @custom_emoji
|
= render 'shared/error_messages', object: @custom_emoji
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.domain_allows.add_new')
|
= t('admin.domain_allows.add_new')
|
||||||
|
|
||||||
= simple_form_for @domain_allow, url: admin_domain_allows_path do |f|
|
= simple_form_for [:admin, @domain_allow] do |f|
|
||||||
= render 'shared/error_messages', object: @domain_allow
|
= render 'shared/error_messages', object: @domain_allow
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.domain_blocks.edit')
|
= t('admin.domain_blocks.edit')
|
||||||
|
|
||||||
= simple_form_for @domain_block, url: admin_domain_block_path(@domain_block) do |form|
|
= simple_form_for [:admin, @domain_block] do |form|
|
||||||
= render 'shared/error_messages', object: @domain_block
|
= render 'shared/error_messages', object: @domain_block
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @domain_block, url: admin_domain_blocks_path do |form|
|
= simple_form_for [:admin, @domain_block] do |form|
|
||||||
= render 'shared/error_messages', object: @domain_block
|
= render 'shared/error_messages', object: @domain_block
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f|
|
= simple_form_for [:admin, @email_domain_block] do |f|
|
||||||
= render 'shared/error_messages', object: @email_domain_block
|
= render 'shared/error_messages', object: @email_domain_block
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
- if policy(:invite).create?
|
- if policy(:invite).create?
|
||||||
%p= t('invites.prompt')
|
%p= t('invites.prompt')
|
||||||
|
|
||||||
= simple_form_for(@invite, url: admin_invites_path) do |form|
|
= simple_form_for [:admin, @invite] do |form|
|
||||||
= render partial: 'invites/form', object: form
|
= render partial: 'invites/form', object: form
|
||||||
|
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('.title')
|
= t('.title')
|
||||||
|
|
||||||
= simple_form_for @ip_block, url: admin_ip_blocks_path do |f|
|
= simple_form_for [:admin, @ip_block] do |f|
|
||||||
= render 'shared/error_messages', object: @ip_block
|
= render 'shared/error_messages', object: @ip_block
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.relays.add_new')
|
= t('admin.relays.add_new')
|
||||||
|
|
||||||
= simple_form_for @relay, url: admin_relays_path do |f|
|
= simple_form_for [:admin, @relay] do |f|
|
||||||
= render 'shared/error_messages', object: @relay
|
= render 'shared/error_messages', object: @relay
|
||||||
|
|
||||||
.field-group
|
.field-group
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.rules.edit')
|
= t('admin.rules.edit')
|
||||||
|
|
||||||
= simple_form_for @rule, url: admin_rule_path(@rule) do |form|
|
= simple_form_for [:admin, @rule] do |form|
|
||||||
= render 'shared/error_messages', object: @rule
|
= render 'shared/error_messages', object: @rule
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
= simple_form_for @rule, url: admin_rules_path do |form|
|
= simple_form_for [:admin, @rule] do |form|
|
||||||
= render 'shared/error_messages', object: @rule
|
= render 'shared/error_messages', object: @rule
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.username_blocks.edit.title')
|
= t('admin.username_blocks.edit.title')
|
||||||
|
|
||||||
= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form|
|
= simple_form_for [:admin, @username_block] do |form|
|
||||||
= render 'shared/error_messages', object: @username_block
|
= render 'shared/error_messages', object: @username_block
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.username_blocks.new.title')
|
= t('admin.username_blocks.new.title')
|
||||||
|
|
||||||
= simple_form_for @username_block, url: admin_username_blocks_path do |form|
|
= simple_form_for [:admin, @username_block] do |form|
|
||||||
= render 'shared/error_messages', object: @username_block
|
= render 'shared/error_messages', object: @username_block
|
||||||
|
|
||||||
= render form
|
= render form
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.webhooks.edit')
|
= t('admin.webhooks.edit')
|
||||||
|
|
||||||
= simple_form_for @webhook, url: admin_webhook_path(@webhook) do |form|
|
= simple_form_for [:admin, @webhook] do |form|
|
||||||
= render form
|
= render form
|
||||||
.actions
|
.actions
|
||||||
= form.button :button, t('generic.save_changes'), type: :submit
|
= form.button :button, t('generic.save_changes'), type: :submit
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
- content_for :page_title do
|
- content_for :page_title do
|
||||||
= t('admin.webhooks.new')
|
= t('admin.webhooks.new')
|
||||||
|
|
||||||
= simple_form_for @webhook, url: admin_webhooks_path do |form|
|
= simple_form_for [:admin, @webhook] do |form|
|
||||||
= render form
|
= render form
|
||||||
.actions
|
.actions
|
||||||
= form.button :button, t('admin.webhooks.add_new'), type: :submit
|
= form.button :button, t('admin.webhooks.add_new'), type: :submit
|
||||||
|
|||||||
@ -57,7 +57,7 @@ class FeedInsertWorker
|
|||||||
|
|
||||||
def notify?(filter_result)
|
def notify?(filter_result)
|
||||||
return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) ||
|
return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) ||
|
||||||
filter_result == :filter
|
update? || filter_result == :filter
|
||||||
|
|
||||||
Follow.find_by(account: @follower, target_account: @status.account)&.notify?
|
Follow.find_by(account: @follower, target_account: @status.account)&.notify?
|
||||||
end
|
end
|
||||||
|
|||||||
@ -48,11 +48,11 @@ class MoveWorker
|
|||||||
source_local_followers
|
source_local_followers
|
||||||
.where(account: @target_account.followers.local)
|
.where(account: @target_account.followers.local)
|
||||||
.in_batches do |follows|
|
.in_batches do |follows|
|
||||||
ListAccount.where(follow: follows).includes(:list).find_each do |list_account|
|
ListAccount.where(follow: follows).includes(:list).find_each do |list_account|
|
||||||
list_account.list.accounts << @target_account
|
list_account.list.accounts << @target_account
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finally, handle the common case of accounts not following the new account
|
# Finally, handle the common case of accounts not following the new account
|
||||||
@ -60,8 +60,8 @@ class MoveWorker
|
|||||||
.where.not(account: @target_account.followers.local)
|
.where.not(account: @target_account.followers.local)
|
||||||
.where.not(account_id: @target_account.id)
|
.where.not(account_id: @target_account.id)
|
||||||
.in_batches do |follows|
|
.in_batches do |follows|
|
||||||
ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id)
|
ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id)
|
||||||
num_moved += follows.update_all(target_account_id: @target_account.id)
|
num_moved += follows.update_all(target_account_id: @target_account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
num_moved
|
num_moved
|
||||||
|
|||||||
@ -4,14 +4,14 @@ class RejectFollowingBlockedUsers < ActiveRecord::Migration[5.2]
|
|||||||
disable_ddl_transaction!
|
disable_ddl_transaction!
|
||||||
|
|
||||||
def up
|
def up
|
||||||
blocked_follows = Follow.find_by_sql(<<-SQL.squish)
|
blocked_follows = Follow.find_by_sql(<<~SQL.squish)
|
||||||
select f.* from follows f
|
select f.* from follows f
|
||||||
inner join blocks b on
|
inner join blocks b on
|
||||||
f.account_id = b.target_account_id and
|
f.account_id = b.target_account_id and
|
||||||
f.target_account_id = b.account_id
|
f.target_account_id = b.account_id
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
domain_blocked_follows = Follow.find_by_sql(<<-SQL.squish)
|
domain_blocked_follows = Follow.find_by_sql(<<~SQL.squish)
|
||||||
select f.* from follows f
|
select f.* from follows f
|
||||||
inner join accounts following on f.account_id = following.id
|
inner join accounts following on f.account_id = following.id
|
||||||
inner join account_domain_blocks b on
|
inner join account_domain_blocks b on
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class CopyStatusStats < ActiveRecord::Migration[5.2]
|
|||||||
say 'Upsert is available, importing counters using the fast method'
|
say 'Upsert is available, importing counters using the fast method'
|
||||||
|
|
||||||
Status.unscoped.select('id').find_in_batches(batch_size: 5_000) do |statuses|
|
Status.unscoped.select('id').find_in_batches(batch_size: 5_000) do |statuses|
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
INSERT INTO status_stats (status_id, reblogs_count, favourites_count, created_at, updated_at)
|
INSERT INTO status_stats (status_id, reblogs_count, favourites_count, created_at, updated_at)
|
||||||
SELECT id, reblogs_count, favourites_count, created_at, updated_at
|
SELECT id, reblogs_count, favourites_count, created_at, updated_at
|
||||||
FROM statuses
|
FROM statuses
|
||||||
|
|||||||
@ -31,7 +31,7 @@ class CopyAccountStats < ActiveRecord::Migration[5.2]
|
|||||||
say 'Upsert is available, importing counters using the fast method'
|
say 'Upsert is available, importing counters using the fast method'
|
||||||
|
|
||||||
MigrationAccount.unscoped.select('id').find_in_batches(batch_size: 5_000) do |accounts|
|
MigrationAccount.unscoped.select('id').find_in_batches(batch_size: 5_000) do |accounts|
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
INSERT INTO account_stats (account_id, statuses_count, following_count, followers_count, created_at, updated_at)
|
INSERT INTO account_stats (account_id, statuses_count, following_count, followers_count, created_at, updated_at)
|
||||||
SELECT id, statuses_count, following_count, followers_count, created_at, updated_at
|
SELECT id, statuses_count, following_count, followers_count, created_at, updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
|
|||||||
@ -5,7 +5,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
|||||||
# Preserve IDs as much as possible to not confuse existing clients.
|
# Preserve IDs as much as possible to not confuse existing clients.
|
||||||
# As long as this migration is irreversible, we do not have to deal with conflicts.
|
# As long as this migration is irreversible, we do not have to deal with conflicts.
|
||||||
safety_assured do
|
safety_assured do
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at)
|
INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at)
|
||||||
SELECT id, id, phrase, whole_word, created_at, updated_at
|
SELECT id, id, phrase, whole_word, created_at, updated_at
|
||||||
FROM custom_filters
|
FROM custom_filters
|
||||||
@ -16,7 +16,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
|||||||
def down
|
def down
|
||||||
# Copy back changes from custom filters guaranteed to be from the old API
|
# Copy back changes from custom filters guaranteed to be from the old API
|
||||||
safety_assured do
|
safety_assured do
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
UPDATE custom_filters
|
UPDATE custom_filters
|
||||||
SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word
|
SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word
|
||||||
FROM custom_filter_keywords
|
FROM custom_filter_keywords
|
||||||
@ -26,7 +26,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
|||||||
|
|
||||||
# Drop every keyword as we can't safely provide a 1:1 mapping
|
# Drop every keyword as we can't safely provide a 1:1 mapping
|
||||||
safety_assured do
|
safety_assured do
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
TRUNCATE custom_filter_keywords RESTART IDENTITY
|
TRUNCATE custom_filter_keywords RESTART IDENTITY
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|||||||
@ -4,7 +4,7 @@ class RemoveBoostsWideningAudience < ActiveRecord::Migration[5.2]
|
|||||||
disable_ddl_transaction!
|
disable_ddl_transaction!
|
||||||
|
|
||||||
def up
|
def up
|
||||||
public_boosts = Status.find_by_sql(<<-SQL.squish)
|
public_boosts = Status.find_by_sql(<<~SQL.squish)
|
||||||
SELECT boost.id
|
SELECT boost.id
|
||||||
FROM statuses AS boost
|
FROM statuses AS boost
|
||||||
LEFT JOIN statuses AS boosted ON boost.reblog_of_id = boosted.id
|
LEFT JOIN statuses AS boosted ON boost.reblog_of_id = boosted.id
|
||||||
|
|||||||
@ -7,7 +7,7 @@ class FixCustomFilterKeywordsIdSeq < ActiveRecord::Migration[6.1]
|
|||||||
# 20220613110711 manually inserts items with set `id` in the database, but
|
# 20220613110711 manually inserts items with set `id` in the database, but
|
||||||
# we also need to bump the sequence number, otherwise
|
# we also need to bump the sequence number, otherwise
|
||||||
safety_assured do
|
safety_assured do
|
||||||
execute <<-SQL.squish
|
execute <<~SQL.squish
|
||||||
BEGIN;
|
BEGIN;
|
||||||
LOCK TABLE custom_filter_keywords IN EXCLUSIVE MODE;
|
LOCK TABLE custom_filter_keywords IN EXCLUSIVE MODE;
|
||||||
SELECT setval('custom_filter_keywords_id_seq'::regclass, id) FROM custom_filter_keywords ORDER BY id DESC LIMIT 1;
|
SELECT setval('custom_filter_keywords_id_seq'::regclass, id) FROM custom_filter_keywords ORDER BY id DESC LIMIT 1;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ module Mastodon::CLI
|
|||||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||||
option :verbose, type: :boolean, default: false, aliases: [:v]
|
option :verbose, type: :boolean, default: false, aliases: [:v]
|
||||||
option :dry_run, type: :boolean, default: false
|
option :dry_run, type: :boolean, default: false
|
||||||
|
option :keep_interacted, type: :boolean, default: false
|
||||||
desc 'remove', 'Remove remote media files, headers or avatars'
|
desc 'remove', 'Remove remote media files, headers or avatars'
|
||||||
long_desc <<-DESC
|
long_desc <<-DESC
|
||||||
Removes locally cached copies of media attachments (and optionally profile
|
Removes locally cached copies of media attachments (and optionally profile
|
||||||
@ -26,6 +27,9 @@ module Mastodon::CLI
|
|||||||
they are removed. In case of avatars and headers, it specifies how old
|
they are removed. In case of avatars and headers, it specifies how old
|
||||||
the last webfinger request and update to the user has to be before they
|
the last webfinger request and update to the user has to be before they
|
||||||
are pruned. It defaults to 7 days.
|
are pruned. It defaults to 7 days.
|
||||||
|
If --keep-interacted is specified, any media attached to a status that
|
||||||
|
was favourited, bookmarked, quoted, replied to, or reblogged by a local
|
||||||
|
account will be preserved.
|
||||||
If --prune-profiles is specified, only avatars and headers are removed.
|
If --prune-profiles is specified, only avatars and headers are removed.
|
||||||
If --remove-headers is specified, only headers are removed.
|
If --remove-headers is specified, only headers are removed.
|
||||||
If --include-follows is specified along with --prune-profiles or
|
If --include-follows is specified along with --prune-profiles or
|
||||||
@ -61,7 +65,11 @@ module Mastodon::CLI
|
|||||||
end
|
end
|
||||||
|
|
||||||
unless options[:prune_profiles] || options[:remove_headers]
|
unless options[:prune_profiles] || options[:remove_headers]
|
||||||
processed, aggregate = parallelize_with_progress(MediaAttachment.cached.remote.where(created_at: ..time_ago)) do |media_attachment|
|
attachment_scope = MediaAttachment.cached.remote.where(created_at: ..time_ago)
|
||||||
|
|
||||||
|
attachment_scope = attachment_scope.without_local_interaction if options[:keep_interacted]
|
||||||
|
|
||||||
|
processed, aggregate = parallelize_with_progress(attachment_scope) do |media_attachment|
|
||||||
next if media_attachment.file.blank?
|
next if media_attachment.file.blank?
|
||||||
|
|
||||||
size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
|
size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
|
||||||
|
|||||||
@ -52,7 +52,7 @@ module Mastodon::CLI
|
|||||||
# Skip accounts followed by local accounts
|
# Skip accounts followed by local accounts
|
||||||
clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed]
|
clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed]
|
||||||
|
|
||||||
ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL', [max_id])
|
ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL', [max_id])
|
||||||
INSERT INTO statuses_to_be_deleted (id)
|
INSERT INTO statuses_to_be_deleted (id)
|
||||||
SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1)
|
SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1)
|
||||||
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id)
|
AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id)
|
||||||
@ -137,7 +137,7 @@ module Mastodon::CLI
|
|||||||
|
|
||||||
ActiveRecord::Base.connection.create_table('conversations_to_be_deleted', force: true)
|
ActiveRecord::Base.connection.create_table('conversations_to_be_deleted', force: true)
|
||||||
|
|
||||||
ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL')
|
ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL')
|
||||||
INSERT INTO conversations_to_be_deleted (id)
|
INSERT INTO conversations_to_be_deleted (id)
|
||||||
SELECT id FROM conversations WHERE NOT EXISTS (SELECT 1 FROM statuses WHERE statuses.conversation_id = conversations.id)
|
SELECT id FROM conversations WHERE NOT EXISTS (SELECT 1 FROM statuses WHERE statuses.conversation_id = conversations.id)
|
||||||
SQL
|
SQL
|
||||||
|
|||||||
@ -73,6 +73,66 @@ RSpec.describe Mastodon::CLI::Media do
|
|||||||
expect(media_attachment.reload.thumbnail).to be_blank
|
expect(media_attachment.reload.thumbnail).to be_blank
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with --keep-interacted' do
|
||||||
|
let(:options) { { keep_interacted: true } }
|
||||||
|
|
||||||
|
let!(:favourited_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
let!(:bookmarked_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
let!(:replied_to_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
let!(:reblogged_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
let!(:remote_quoted_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
let!(:remote_quoting_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
local_account = Fabricate(:account, username: 'alice')
|
||||||
|
remote_account = Fabricate(:account, username: 'bob', domain: 'example.com')
|
||||||
|
|
||||||
|
favourited_status = Fabricate(:status, account: remote_account)
|
||||||
|
bookmarked_status = Fabricate(:status, account: remote_account)
|
||||||
|
replied_to_status = Fabricate(:status, account: remote_account)
|
||||||
|
reblogged_status = Fabricate(:status, account: remote_account)
|
||||||
|
|
||||||
|
favourited_media.update!(status: favourited_status)
|
||||||
|
bookmarked_media.update!(status: bookmarked_status)
|
||||||
|
replied_to_media.update!(status: replied_to_status)
|
||||||
|
reblogged_media.update!(status: reblogged_status)
|
||||||
|
|
||||||
|
local_quoting_status = Fabricate(:status, account: local_account)
|
||||||
|
remote_quoted_status = Fabricate(:status, account: remote_account)
|
||||||
|
local_status_being_quoted = Fabricate(:status, account: local_account)
|
||||||
|
remote_quoting_status = Fabricate(:status, account: remote_account)
|
||||||
|
|
||||||
|
remote_quoted_media.update!(status: remote_quoted_status)
|
||||||
|
remote_quoting_media.update!(status: remote_quoting_status)
|
||||||
|
|
||||||
|
non_interacted_status = Fabricate(:status, account: remote_account)
|
||||||
|
|
||||||
|
media_attachment.update(status: non_interacted_status)
|
||||||
|
|
||||||
|
Fabricate(:favourite, account: local_account, status: favourited_status)
|
||||||
|
Fabricate(:bookmark, account: local_account, status: bookmarked_status)
|
||||||
|
Fabricate(:status, account: local_account, in_reply_to_id: replied_to_status.id)
|
||||||
|
Fabricate(:status, account: local_account, reblog: reblogged_status)
|
||||||
|
Fabricate(:quote, account: local_account, status: local_quoting_status, quoted_status: remote_quoted_status)
|
||||||
|
Fabricate(:quote, account: remote_account, status: remote_quoting_status, quoted_status: local_status_being_quoted)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps media associated with statuses that have been favourited, bookmarked, replied to, or reblogged by a local account' do
|
||||||
|
expect { subject }
|
||||||
|
.to output_results('Removed 1')
|
||||||
|
|
||||||
|
expect(favourited_media.reload.file).to be_present
|
||||||
|
expect(bookmarked_media.reload.file).to be_present
|
||||||
|
expect(replied_to_media.reload.file).to be_present
|
||||||
|
expect(reblogged_media.reload.file).to be_present
|
||||||
|
expect(remote_quoted_media.reload.file).to be_present
|
||||||
|
expect(remote_quoting_media.reload.file).to be_present
|
||||||
|
|
||||||
|
expect(media_attachment.reload.file).to be_blank
|
||||||
|
expect(media_attachment.reload.thumbnail).to be_blank
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,8 @@ const dotenvFilePath = path.resolve(
|
|||||||
);
|
);
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: dotenvFilePath
|
path: dotenvFilePath,
|
||||||
|
quiet: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
initializeLogLevel(process.env, environment);
|
initializeLogLevel(process.env, environment);
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^17.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
|
|||||||
17
yarn.lock
17
yarn.lock
@ -3007,7 +3007,7 @@ __metadata:
|
|||||||
"@types/ws": "npm:^8.5.9"
|
"@types/ws": "npm:^8.5.9"
|
||||||
bufferutil: "npm:^4.0.7"
|
bufferutil: "npm:^4.0.7"
|
||||||
cors: "npm:^2.8.5"
|
cors: "npm:^2.8.5"
|
||||||
dotenv: "npm:^16.0.3"
|
dotenv: "npm:^17.0.0"
|
||||||
express: "npm:^5.1.0"
|
express: "npm:^5.1.0"
|
||||||
globals: "npm:^17.0.0"
|
globals: "npm:^17.0.0"
|
||||||
ioredis: "npm:^5.3.2"
|
ioredis: "npm:^5.3.2"
|
||||||
@ -5545,13 +5545,13 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"axios@npm:^1.4.0":
|
"axios@npm:^1.4.0":
|
||||||
version: 1.13.3
|
version: 1.13.4
|
||||||
resolution: "axios@npm:1.13.3"
|
resolution: "axios@npm:1.13.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: "npm:^1.15.6"
|
follow-redirects: "npm:^1.15.6"
|
||||||
form-data: "npm:^4.0.4"
|
form-data: "npm:^4.0.4"
|
||||||
proxy-from-env: "npm:^1.1.0"
|
proxy-from-env: "npm:^1.1.0"
|
||||||
checksum: 10c0/86f0770624d9f14a3f8f8738c8b8f7f7fbb7b0d4ad38757db1de2d71007a0311bc597661c5ff4b4a9ee6350c6956a7282e3a281fcdf7b5b32054e35a8801e2ce
|
checksum: 10c0/474c00b7d71f4de4ad562589dae6b615149df7c2583bbc5ebba96229f3f85bfb0775d23705338df072f12e48d3e85685c065a3cf6855d58968a672d19214c728
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -6643,13 +6643,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"dotenv@npm:^16.0.3, dotenv@npm:^16.4.2":
|
"dotenv@npm:^16.4.2":
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
resolution: "dotenv@npm:16.6.1"
|
resolution: "dotenv@npm:16.6.1"
|
||||||
checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc
|
checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"dotenv@npm:^17.0.0":
|
||||||
|
version: 17.2.3
|
||||||
|
resolution: "dotenv@npm:17.2.3"
|
||||||
|
checksum: 10c0/c884403209f713214a1b64d4d1defa4934c2aa5b0002f5a670ae298a51e3c3ad3ba79dfee2f8df49f01ae74290fcd9acdb1ab1d09c7bfb42b539036108bb2ba0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
|
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "dunder-proto@npm:1.0.1"
|
resolution: "dunder-proto@npm:1.0.1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user