Merge commit 'd0502ac3c1630e281fda5492cbc28390262b0aeb' into glitch-soc/merge-upstream
This commit is contained in:
commit
d62a7775b5
@ -754,7 +754,7 @@ GEM
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.82.1)
|
||||
rubocop (1.84.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@ -762,7 +762,7 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.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)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
|
||||
@ -233,6 +233,7 @@ module LanguagesHelper
|
||||
'es-AR': 'Español (Argentina)',
|
||||
'es-MX': 'Español (México)',
|
||||
'fr-CA': 'Français (Canadien)',
|
||||
'nan-TW': '臺語 (Hô-ló話)',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
'pt-PT': 'Português (Portugal)',
|
||||
'sr-Latn': 'Srpski (latinica)',
|
||||
|
||||
@ -9,7 +9,7 @@ import type {
|
||||
ApiWrappedCollectionJSON,
|
||||
ApiCollectionWithAccountsJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
ApiPatchCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
ApiCollectionsJSON,
|
||||
} from '../api_types/collections';
|
||||
|
||||
@ -19,7 +19,7 @@ export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
||||
export const apiUpdateCollection = ({
|
||||
id,
|
||||
...collection
|
||||
}: ApiPatchCollectionPayload) =>
|
||||
}: ApiUpdateCollectionPayload) =>
|
||||
apiRequestPut<ApiWrappedCollectionJSON>(
|
||||
`v1_alpha/collections/${id}`,
|
||||
collection,
|
||||
@ -29,7 +29,7 @@ export const apiDeleteCollection = (collectionId: string) =>
|
||||
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
||||
|
||||
export const apiGetCollection = (collectionId: string) =>
|
||||
apiRequestGet<ApiCollectionWithAccountsJSON[]>(
|
||||
apiRequestGet<ApiCollectionWithAccountsJSON>(
|
||||
`v1_alpha/collections/${collectionId}`,
|
||||
);
|
||||
|
||||
|
||||
@ -70,10 +70,10 @@ type CommonPayloadFields = Pick<
|
||||
ApiCollectionJSON,
|
||||
'name' | 'description' | 'sensitive' | 'discoverable'
|
||||
> & {
|
||||
tag?: string;
|
||||
tag_name?: string;
|
||||
};
|
||||
|
||||
export interface ApiPatchCollectionPayload extends Partial<CommonPayloadFields> {
|
||||
export interface ApiUpdateCollectionPayload extends Partial<CommonPayloadFields> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export { TextInputField } from './text_input_field';
|
||||
export { TextAreaField } from './text_area_field';
|
||||
export { ToggleField, PlainToggleField } from './toggle_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 { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
@ -66,156 +67,3 @@ export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
</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);
|
||||
appearance: none;
|
||||
background: none;
|
||||
padding: 8px;
|
||||
padding: 6px 8px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: var(--color-text-primary);
|
||||
display: inline-flex;
|
||||
|
||||
@ -5,6 +5,7 @@ import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import type { IconProp } from '../icon';
|
||||
@ -23,7 +24,7 @@ export interface TagProps {
|
||||
|
||||
export const Tag = forwardRef<
|
||||
HTMLButtonElement,
|
||||
TagProps & ComponentPropsWithoutRef<'button'>
|
||||
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
|
||||
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||
if (!name) {
|
||||
return null;
|
||||
@ -34,6 +35,7 @@ export const Tag = forwardRef<
|
||||
type='button'
|
||||
ref={ref}
|
||||
className={classNames(className, classes.tag, active && classes.active)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||
{typeof name === 'string' ? `#${name}` : name}
|
||||
@ -45,10 +47,13 @@ Tag.displayName = 'Tag';
|
||||
|
||||
export const EditableTag = forwardRef<
|
||||
HTMLSpanElement,
|
||||
TagProps & {
|
||||
onRemove: () => void;
|
||||
removeIcon?: IconProp;
|
||||
} & ComponentPropsWithoutRef<'span'>
|
||||
OmitUnion<
|
||||
ComponentPropsWithoutRef<'span'>,
|
||||
TagProps & {
|
||||
onRemove: () => void;
|
||||
removeIcon?: IconProp;
|
||||
}
|
||||
>
|
||||
>(
|
||||
(
|
||||
{
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './style.module.css';
|
||||
import { EditableTag, Tag } from './tag';
|
||||
import type { TagProps } from './tag';
|
||||
@ -17,31 +19,39 @@ export type TagsProps = {
|
||||
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||
);
|
||||
|
||||
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
|
||||
if (onRemove) {
|
||||
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
|
||||
({ 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 (
|
||||
<div className={classes.tagsWrapper}>
|
||||
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
|
||||
{tags.map((tag) => (
|
||||
<MappedTag
|
||||
<Tag
|
||||
key={tag.name}
|
||||
active={tag.name === active}
|
||||
onRemove={onRemove}
|
||||
{...tag}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tagsWrapper}>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
Tags.displayName = 'Tags';
|
||||
|
||||
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||
onRemove,
|
||||
|
||||
@ -162,11 +162,13 @@ export const AccountHeader: React.FC<{
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{me && account.id !== me && isRedesignEnabled() ? (
|
||||
<AccountNoteRedesign accountId={accountId} />
|
||||
) : (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
{me &&
|
||||
account.id !== me &&
|
||||
(isRedesignEnabled() ? (
|
||||
<AccountNoteRedesign accountId={accountId} />
|
||||
) : (
|
||||
<AccountNote accountId={accountId} />
|
||||
))}
|
||||
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
if (isRedesignEnabled()) {
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
</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 { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import { FeaturedTags } from './featured_tags';
|
||||
import { AccountFilters } from './filters';
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
@ -135,6 +136,7 @@ const Prepend: FC<{
|
||||
<>
|
||||
<AccountHeader accountId={accountId} hideTabs />
|
||||
<AccountFilters />
|
||||
<FeaturedTags accountId={accountId} />
|
||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -35,3 +35,25 @@
|
||||
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 ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import {
|
||||
createCollection,
|
||||
fetchAccountCollections,
|
||||
selectMyCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
@ -49,13 +48,14 @@ const ListItem: React.FC<{
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_DELETE_LIST',
|
||||
modalType: 'CONFIRM_DELETE_COLLECTION',
|
||||
modalProps: {
|
||||
listId: id,
|
||||
name,
|
||||
id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
}, [dispatch, id, name]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
@ -67,7 +67,7 @@ const ListItem: React.FC<{
|
||||
|
||||
return (
|
||||
<div className='lists__item'>
|
||||
<Link to={`/collections/${id}`} className='lists__item__title'>
|
||||
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
|
||||
<span>{name}</span>
|
||||
</Link>
|
||||
|
||||
@ -94,24 +94,6 @@ export const Collections: React.FC<{
|
||||
void dispatch(fetchAccountCollections({ accountId: 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 =
|
||||
status === 'error' ? (
|
||||
<FormattedMessage
|
||||
@ -152,7 +134,6 @@ export const Collections: React.FC<{
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.create)}
|
||||
aria-label={intl.formatMessage(messages.create)}
|
||||
onClick={addDummyCollection}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</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 { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
export { ConfirmDeleteCollectionModal } from './delete_collection';
|
||||
export {
|
||||
ConfirmReplyModal,
|
||||
ConfirmEditStatusModal,
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
ConfirmationModal,
|
||||
ConfirmDeleteStatusModal,
|
||||
ConfirmDeleteListModal,
|
||||
ConfirmDeleteCollectionModal,
|
||||
ConfirmReplyModal,
|
||||
ConfirmEditStatusModal,
|
||||
ConfirmUnblockModal,
|
||||
@ -57,6 +58,7 @@ export const MODAL_COMPONENTS = {
|
||||
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
|
||||
'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }),
|
||||
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
||||
'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }),
|
||||
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
||||
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
||||
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
|
||||
|
||||
@ -64,6 +64,7 @@ import {
|
||||
ListEdit,
|
||||
ListMembers,
|
||||
Collections,
|
||||
CollectionsEditor,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
Mutes,
|
||||
@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} 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() &&
|
||||
<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 () {
|
||||
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.rules": "Κανόνες διακομιστή",
|
||||
"account.account_note_header": "Προσωπική σημείωση",
|
||||
"account.activity": "Δραστηριότητα",
|
||||
"account.add_note": "Προσθέστε μια προσωπική σημείωση",
|
||||
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
|
||||
"account.badges.bot": "Αυτοματοποιημένος",
|
||||
@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "Ετικέτες",
|
||||
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
|
||||
"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_back": "Ακολούθησε και εσύ",
|
||||
"account.follow_back_short": "Ακολούθησε και εσύ",
|
||||
@ -1030,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Ειδοποιήσεις",
|
||||
"tabs_bar.publish": "Νέα Ανάρτηση",
|
||||
"tabs_bar.search": "Αναζήτηση",
|
||||
"tag.remove": "Αφαίρεση",
|
||||
"terms_of_service.effective_as_of": "Ενεργό από {date}",
|
||||
"terms_of_service.title": "Όροι Παροχής Υπηρεσιών",
|
||||
"terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}",
|
||||
|
||||
@ -212,21 +212,31 @@
|
||||
"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.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_collection": "Create 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.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.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||
"collections.view_collection": "View collection",
|
||||
"column.about": "About",
|
||||
"column.blocks": "Blocked users",
|
||||
"column.bookmarks": "Bookmarks",
|
||||
"column.collections": "My collections",
|
||||
"column.community": "Local timeline",
|
||||
"column.create_collection": "Create collection",
|
||||
"column.create_list": "Create list",
|
||||
"column.direct": "Private mentions",
|
||||
"column.directory": "Browse profiles",
|
||||
"column.domain_blocks": "Blocked domains",
|
||||
"column.edit_collection": "Edit collection",
|
||||
"column.edit_list": "Edit list",
|
||||
"column.favourites": "Favorites",
|
||||
"column.firehose": "Live feeds",
|
||||
@ -281,6 +291,9 @@
|
||||
"confirmations.delete.confirm": "Delete",
|
||||
"confirmations.delete.message": "Are you sure you want to delete this 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.message": "Are you sure you want to permanently delete this list?",
|
||||
"confirmations.delete_list.title": "Delete list?",
|
||||
@ -427,6 +440,7 @@
|
||||
"featured_carousel.current": "<sr>Post</sr> {current, number} / {max, number}",
|
||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||
"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_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.",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}",
|
||||
"about.rules": "Reglas del servidor",
|
||||
"account.account_note_header": "Nota personal",
|
||||
"account.activity": "Actividad",
|
||||
"account.add_note": "Agregar una nota personal",
|
||||
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
|
||||
"account.badges.bot": "Automatizada",
|
||||
@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured_tags.last_status_at": "Último mensaje: {date}",
|
||||
"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_back": "Seguir",
|
||||
"account.follow_back_short": "Seguir",
|
||||
@ -1030,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Notificaciones",
|
||||
"tabs_bar.publish": "Nuevo mensaje",
|
||||
"tabs_bar.search": "Buscar",
|
||||
"tag.remove": "Quitar",
|
||||
"terms_of_service.effective_as_of": "Efectivo a partir de {date}",
|
||||
"terms_of_service.title": "Términos del servicio",
|
||||
"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.rules": "Reglas del servidor",
|
||||
"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 las listas",
|
||||
"account.badges.bot": "Automatizada",
|
||||
@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||
"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_back": "Seguir también",
|
||||
"account.follow_back_short": "Seguir también",
|
||||
@ -1030,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Notificaciones",
|
||||
"tabs_bar.publish": "Nueva publicación",
|
||||
"tabs_bar.search": "Buscar",
|
||||
"tag.remove": "Eliminar",
|
||||
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
||||
"terms_of_service.title": "Condiciones del servicio",
|
||||
"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.rules": "Reglas del servidor",
|
||||
"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.badges.bot": "Automatizada",
|
||||
"account.badges.group": "Grupo",
|
||||
@ -27,6 +29,7 @@
|
||||
"account.direct": "Mención privada a @{name}",
|
||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo",
|
||||
"account.domain_blocking": "Bloqueando dominio",
|
||||
"account.edit_note": "Eidtar nota personal",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.edit_profile_short": "Editar",
|
||||
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
||||
@ -39,6 +42,12 @@
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||
"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_back": "Seguir también",
|
||||
"account.follow_back_short": "Seguir también",
|
||||
@ -72,6 +81,14 @@
|
||||
"account.muting": "Silenciando",
|
||||
"account.mutual": "Os seguís mutuamente",
|
||||
"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.posts": "Publicaciones",
|
||||
"account.posts_with_replies": "Publicaciones y respuestas",
|
||||
@ -1020,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Notificaciones",
|
||||
"tabs_bar.publish": "Nueva Publicación",
|
||||
"tabs_bar.search": "Buscar",
|
||||
"tag.remove": "Eliminar",
|
||||
"terms_of_service.effective_as_of": "En vigor a partir del {date}",
|
||||
"terms_of_service.title": "Términos del servicio",
|
||||
"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.rules": "Regras do servidor",
|
||||
"account.account_note_header": "Nota persoal",
|
||||
"account.activity": "Actividade",
|
||||
"account.add_note": "Engadir nota persoal",
|
||||
"account.add_or_remove_from_list": "Engadir ou eliminar das listaxes",
|
||||
"account.badges.bot": "Automatizada",
|
||||
@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "Cancelos",
|
||||
"account.featured_tags.last_status_at": "Última publicación o {date}",
|
||||
"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_back": "Seguir tamén",
|
||||
"account.follow_back_short": "Seguir tamén",
|
||||
@ -1030,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Notificacións",
|
||||
"tabs_bar.publish": "Nova publicación",
|
||||
"tabs_bar.search": "Buscar",
|
||||
"tag.remove": "Retirar",
|
||||
"terms_of_service.effective_as_of": "Con efecto desde o {date}",
|
||||
"terms_of_service.title": "Condicións do Servizo",
|
||||
"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.rules": "Tenarreglar",
|
||||
"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.badges.bot": "Robot",
|
||||
"account.badges.group": "Gruppe",
|
||||
@ -27,6 +29,7 @@
|
||||
"account.direct": "Nemn @{name} privat",
|
||||
"account.disable_notifications": "Slutt å varsle meg når @{name} skriv innlegg",
|
||||
"account.domain_blocking": "Blokkerer domenet",
|
||||
"account.edit_note": "Rediger det personlege notatet",
|
||||
"account.edit_profile": "Rediger profil",
|
||||
"account.edit_profile_short": "Rediger",
|
||||
"account.enable_notifications": "Varsle meg når @{name} skriv innlegg",
|
||||
@ -39,6 +42,12 @@
|
||||
"account.featured.hashtags": "Emneknaggar",
|
||||
"account.featured_tags.last_status_at": "Sist nytta {date}",
|
||||
"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_back": "Fylg tilbake",
|
||||
"account.follow_back_short": "Fylg tilbake",
|
||||
@ -72,6 +81,14 @@
|
||||
"account.muting": "Dempa",
|
||||
"account.mutual": "De fylgjer kvarandre",
|
||||
"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.posts": "Tut",
|
||||
"account.posts_with_replies": "Tut og svar",
|
||||
@ -187,6 +204,7 @@
|
||||
"bundle_modal_error.close": "Lat att",
|
||||
"bundle_modal_error.message": "Noko gjekk gale då denne sida vart lasta.",
|
||||
"bundle_modal_error.retry": "Prøv igjen",
|
||||
"callout.dismiss": "Avvis",
|
||||
"carousel.current": "<sr>Side</sr> {current, number} / {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.",
|
||||
@ -194,9 +212,16 @@
|
||||
"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.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.blocks": "Blokkerte brukarar",
|
||||
"column.bookmarks": "Bokmerke",
|
||||
"column.collections": "Samlingane mine",
|
||||
"column.community": "Lokal tidsline",
|
||||
"column.create_list": "Lag liste",
|
||||
"column.direct": "Private omtaler",
|
||||
@ -454,6 +479,7 @@
|
||||
"footer.source_code": "Vis kjeldekode",
|
||||
"footer.status": "Status",
|
||||
"footer.terms_of_service": "Brukarvilkår",
|
||||
"form_field.optional": "(valfritt)",
|
||||
"generic.saved": "Lagra",
|
||||
"getting_started.heading": "Kom i gang",
|
||||
"hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}",
|
||||
@ -790,6 +816,7 @@
|
||||
"privacy.private.short": "Fylgjarar",
|
||||
"privacy.public.long": "Kven som helst på og av Mastodon",
|
||||
"privacy.public.short": "Offentleg",
|
||||
"privacy.quote.anyone": "{visibility}, det er lov å sitera",
|
||||
"privacy.quote.disabled": "{visibility}, ingen kan sitera",
|
||||
"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.",
|
||||
@ -1010,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "Varsel",
|
||||
"tabs_bar.publish": "Nytt innlegg",
|
||||
"tabs_bar.search": "Søk",
|
||||
"tag.remove": "Fjern",
|
||||
"terms_of_service.effective_as_of": "I kraft frå {date}",
|
||||
"terms_of_service.title": "Bruksvilkår",
|
||||
"terms_of_service.upcoming_changes_on": "Komande endringar {date}",
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
"about.powered_by": "由 {mastodon} 驱动的去中心化社交媒体",
|
||||
"about.rules": "站点规则",
|
||||
"account.account_note_header": "个人备注",
|
||||
"account.activity": "活动",
|
||||
"account.add_note": "添加个人备注",
|
||||
"account.add_or_remove_from_list": "从列表中添加或移除",
|
||||
"account.badges.bot": "机器人",
|
||||
"account.badges.group": "群组",
|
||||
@ -27,6 +29,7 @@
|
||||
"account.direct": "私下提及 @{name}",
|
||||
"account.disable_notifications": "当 @{name} 发布嘟文时不要通知我",
|
||||
"account.domain_blocking": "正在屏蔽中的域名",
|
||||
"account.edit_note": "编辑个人备注",
|
||||
"account.edit_profile": "修改个人资料",
|
||||
"account.edit_profile_short": "编辑",
|
||||
"account.enable_notifications": "当 @{name} 发布嘟文时通知我",
|
||||
@ -39,6 +42,12 @@
|
||||
"account.featured.hashtags": "话题",
|
||||
"account.featured_tags.last_status_at": "上次发言于 {date}",
|
||||
"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_back": "回关",
|
||||
"account.follow_back_short": "回关",
|
||||
@ -72,6 +81,14 @@
|
||||
"account.muting": "正在静音",
|
||||
"account.mutual": "你们互相关注",
|
||||
"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.posts": "嘟文",
|
||||
"account.posts_with_replies": "嘟文和回复",
|
||||
@ -1020,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "通知",
|
||||
"tabs_bar.publish": "新嘟文",
|
||||
"tabs_bar.search": "搜索",
|
||||
"tag.remove": "移除",
|
||||
"terms_of_service.effective_as_of": "自 {date} 起生效",
|
||||
"terms_of_service.title": "服务条款",
|
||||
"terms_of_service.upcoming_changes_on": "{date} 起即将生效的更改",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"about.powered_by": "由 {mastodon} 提供之去中心化社群媒體",
|
||||
"about.rules": "伺服器規則",
|
||||
"account.account_note_header": "個人備註",
|
||||
"account.activity": "活動",
|
||||
"account.add_note": "新增個人備註",
|
||||
"account.add_or_remove_from_list": "自列表中新增或移除",
|
||||
"account.badges.bot": "機器人",
|
||||
@ -41,6 +42,12 @@
|
||||
"account.featured.hashtags": "主題標籤",
|
||||
"account.featured_tags.last_status_at": "上次發嘟於 {date}",
|
||||
"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_back": "跟隨回去",
|
||||
"account.follow_back_short": "跟隨回去",
|
||||
@ -1030,6 +1037,7 @@
|
||||
"tabs_bar.notifications": "通知",
|
||||
"tabs_bar.publish": "新增嘟文",
|
||||
"tabs_bar.search": "搜尋",
|
||||
"tag.remove": "移除",
|
||||
"terms_of_service.effective_as_of": "{date} 起生效",
|
||||
"terms_of_service.title": "服務條款",
|
||||
"terms_of_service.upcoming_changes_on": "{date} 起即將發生之異動",
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { importFetchedAccounts } from '@/mastodon/actions/importer';
|
||||
import {
|
||||
apiCreateCollection,
|
||||
apiGetAccountCollections,
|
||||
// apiGetCollection,
|
||||
apiUpdateCollection,
|
||||
apiGetCollection,
|
||||
apiDeleteCollection,
|
||||
} from '@/mastodon/api/collections';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
} from '@/mastodon/api_types/collections';
|
||||
import {
|
||||
createAppSelector,
|
||||
@ -59,10 +63,11 @@ const collectionSlice = createSlice({
|
||||
};
|
||||
});
|
||||
|
||||
builder.addCase(fetchAccountCollections.fulfilled, (state, actions) => {
|
||||
const { collections } = actions.payload;
|
||||
builder.addCase(fetchAccountCollections.fulfilled, (state, action) => {
|
||||
const { collections } = action.payload;
|
||||
|
||||
const collectionsMap: Record<string, ApiCollectionJSON> = {};
|
||||
const collectionsMap: Record<string, ApiCollectionJSON> =
|
||||
state.collections;
|
||||
const collectionIds: string[] = [];
|
||||
|
||||
collections.forEach((collection) => {
|
||||
@ -72,12 +77,40 @@ const collectionSlice = createSlice({
|
||||
});
|
||||
|
||||
state.collections = collectionsMap;
|
||||
state.accountCollections[actions.meta.arg.accountId] = {
|
||||
state.accountCollections[action.meta.arg.accountId] = {
|
||||
collectionIds,
|
||||
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
|
||||
*/
|
||||
@ -86,6 +119,7 @@ const collectionSlice = createSlice({
|
||||
const { collection } = actions.payload;
|
||||
|
||||
state.collections[collection.id] = collection;
|
||||
|
||||
if (state.accountCollections[collection.account_id]) {
|
||||
state.accountCollections[collection.account_id]?.collectionIds.unshift(
|
||||
collection.id,
|
||||
@ -105,13 +139,17 @@ export const fetchAccountCollections = createDataLoadingThunk(
|
||||
({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId),
|
||||
);
|
||||
|
||||
// To be added soon…
|
||||
//
|
||||
// export const fetchCollection = createDataLoadingThunk(
|
||||
// `${collectionSlice.name}/fetchCollection`,
|
||||
// ({ collectionId }: { collectionId: string }) =>
|
||||
// apiGetCollection(collectionId),
|
||||
// );
|
||||
export const fetchCollection = createDataLoadingThunk(
|
||||
`${collectionSlice.name}/fetchCollection`,
|
||||
({ collectionId }: { collectionId: string }) =>
|
||||
apiGetCollection(collectionId),
|
||||
(payload, { dispatch }) => {
|
||||
if (payload.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(payload.accounts));
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
);
|
||||
|
||||
export const createCollection = createDataLoadingThunk(
|
||||
`${collectionSlice.name}/createCollection`,
|
||||
@ -119,6 +157,18 @@ export const createCollection = createDataLoadingThunk(
|
||||
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;
|
||||
|
||||
/**
|
||||
@ -136,7 +186,7 @@ export const selectMyCollections = createAppSelector(
|
||||
(state) => state.collections.accountCollections,
|
||||
(state) => state.collections.collections,
|
||||
],
|
||||
(me, collectionsByAccountId, collectionsById) => {
|
||||
(me, collectionsByAccountId, collectionsMap) => {
|
||||
const myCollectionsQuery = collectionsByAccountId[me];
|
||||
|
||||
if (!myCollectionsQuery) {
|
||||
@ -151,7 +201,7 @@ export const selectMyCollections = createAppSelector(
|
||||
return {
|
||||
status,
|
||||
collections: collectionIds
|
||||
.map((id) => collectionsById[id])
|
||||
.map((id) => collectionsMap[id])
|
||||
.filter((c) => !!c),
|
||||
} satisfies AccountCollectionQuery;
|
||||
},
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { Record as ImmutableRecord } from 'immutable';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { Record as ImmutableRecord, List as ImmutableList } from 'immutable';
|
||||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { accountDefaultValues } from 'mastodon/models/account';
|
||||
import type { Account, AccountShape } from 'mastodon/models/account';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import { createAppSelector } from 'mastodon/store';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
import type { ApiHashtagJSON } from '../api_types/tags';
|
||||
|
||||
const getAccountBase = (state: RootState, id: string) =>
|
||||
state.accounts.get(id, null);
|
||||
|
||||
@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord<FullAccountShape>({
|
||||
});
|
||||
|
||||
export function makeGetAccount() {
|
||||
return createSelector(
|
||||
return createAppSelector(
|
||||
[getAccountBase, getAccountRelationship, getAccountMoved],
|
||||
(base, relationship, moved) => {
|
||||
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: RootState, id: string) =>
|
||||
(state, id: string) => state.accounts.get(id)?.hidden,
|
||||
(state, id: string) =>
|
||||
state.relationships.get(id)?.following ||
|
||||
state.relationships.get(id)?.requested,
|
||||
(state: RootState, id: string) => id === me,
|
||||
(_, id: string) => id === me,
|
||||
],
|
||||
(hidden, followingOrRequested, isSelf) => {
|
||||
return hidden && !(isSelf || followingOrRequested);
|
||||
},
|
||||
);
|
||||
|
||||
export const getAccountFamiliarFollowers = createSelector(
|
||||
export const getAccountFamiliarFollowers = createAppSelector(
|
||||
[
|
||||
(state: RootState) => state.accounts,
|
||||
(state: RootState, id: string) => state.accounts_familiar_followers[id],
|
||||
(state) => state.accounts,
|
||||
(state, id: string) => state.accounts_familiar_followers[id],
|
||||
],
|
||||
(accounts, accounts_familiar_followers) => {
|
||||
if (!accounts_familiar_followers) return null;
|
||||
@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector(
|
||||
.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 OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
|
||||
|
||||
@ -22,7 +22,7 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
|
||||
end
|
||||
|
||||
def sql_query_string
|
||||
<<-SQL.squish
|
||||
<<~SQL.squish
|
||||
SELECT accounts.domain, count(*) AS value
|
||||
FROM statuses
|
||||
INNER JOIN accounts ON accounts.id = statuses.account_id
|
||||
|
||||
@ -49,7 +49,7 @@ module Status::ThreadingConcern
|
||||
end
|
||||
|
||||
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)
|
||||
AS (
|
||||
SELECT id, in_reply_to_id, ARRAY[id]
|
||||
@ -73,7 +73,7 @@ module Status::ThreadingConcern
|
||||
depth += 1 if depth.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 (
|
||||
SELECT id, ARRAY[id]
|
||||
FROM statuses
|
||||
|
||||
@ -214,6 +214,14 @@ class MediaAttachment < ApplicationRecord
|
||||
scope :remote, -> { where.not(remote_url: '') }
|
||||
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
||||
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
|
||||
|
||||
|
||||
@ -92,6 +92,7 @@ class Status < ApplicationRecord
|
||||
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_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
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ class NotifyService < BaseService
|
||||
# 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
|
||||
# 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 (
|
||||
SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0
|
||||
FROM statuses s
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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
|
||||
|
||||
.fields-group
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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
|
||||
|
||||
.fields-group
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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
|
||||
|
||||
.fields-group
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
- if policy(:invite).create?
|
||||
%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
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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
|
||||
|
||||
.fields-group
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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
|
||||
|
||||
.field-group
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
%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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= 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 form
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= t('admin.webhooks.edit')
|
||||
|
||||
= simple_form_for @webhook, url: admin_webhook_path(@webhook) do |form|
|
||||
= simple_form_for [:admin, @webhook] do |form|
|
||||
= render form
|
||||
.actions
|
||||
= form.button :button, t('generic.save_changes'), type: :submit
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
- content_for :page_title do
|
||||
= t('admin.webhooks.new')
|
||||
|
||||
= simple_form_for @webhook, url: admin_webhooks_path do |form|
|
||||
= simple_form_for [:admin, @webhook] do |form|
|
||||
= render form
|
||||
.actions
|
||||
= form.button :button, t('admin.webhooks.add_new'), type: :submit
|
||||
|
||||
@ -57,7 +57,7 @@ class FeedInsertWorker
|
||||
|
||||
def notify?(filter_result)
|
||||
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?
|
||||
end
|
||||
|
||||
@ -48,11 +48,11 @@ class MoveWorker
|
||||
source_local_followers
|
||||
.where(account: @target_account.followers.local)
|
||||
.in_batches do |follows|
|
||||
ListAccount.where(follow: follows).includes(:list).find_each do |list_account|
|
||||
list_account.list.accounts << @target_account
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
ListAccount.where(follow: follows).includes(:list).find_each do |list_account|
|
||||
list_account.list.accounts << @target_account
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# 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_id: @target_account.id)
|
||||
.in_batches do |follows|
|
||||
ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id)
|
||||
num_moved += follows.update_all(target_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)
|
||||
end
|
||||
|
||||
num_moved
|
||||
|
||||
@ -4,14 +4,14 @@ class RejectFollowingBlockedUsers < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
blocked_follows = Follow.find_by_sql(<<-SQL.squish)
|
||||
blocked_follows = Follow.find_by_sql(<<~SQL.squish)
|
||||
select f.* from follows f
|
||||
inner join blocks b on
|
||||
f.account_id = b.target_account_id and
|
||||
f.target_account_id = b.account_id
|
||||
SQL
|
||||
|
||||
domain_blocked_follows = Follow.find_by_sql(<<-SQL.squish)
|
||||
domain_blocked_follows = Follow.find_by_sql(<<~SQL.squish)
|
||||
select f.* from follows f
|
||||
inner join accounts following on f.account_id = following.id
|
||||
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'
|
||||
|
||||
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)
|
||||
SELECT id, reblogs_count, favourites_count, created_at, updated_at
|
||||
FROM statuses
|
||||
|
||||
@ -31,7 +31,7 @@ class CopyAccountStats < ActiveRecord::Migration[5.2]
|
||||
say 'Upsert is available, importing counters using the fast method'
|
||||
|
||||
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)
|
||||
SELECT id, statuses_count, following_count, followers_count, created_at, updated_at
|
||||
FROM accounts
|
||||
|
||||
@ -5,7 +5,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
||||
# 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.
|
||||
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)
|
||||
SELECT id, id, phrase, whole_word, created_at, updated_at
|
||||
FROM custom_filters
|
||||
@ -16,7 +16,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1]
|
||||
def down
|
||||
# Copy back changes from custom filters guaranteed to be from the old API
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
execute <<~SQL.squish
|
||||
UPDATE custom_filters
|
||||
SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word
|
||||
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
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
execute <<~SQL.squish
|
||||
TRUNCATE custom_filter_keywords RESTART IDENTITY
|
||||
SQL
|
||||
end
|
||||
|
||||
@ -4,7 +4,7 @@ class RemoveBoostsWideningAudience < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
public_boosts = Status.find_by_sql(<<-SQL.squish)
|
||||
public_boosts = Status.find_by_sql(<<~SQL.squish)
|
||||
SELECT boost.id
|
||||
FROM statuses AS boost
|
||||
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
|
||||
# we also need to bump the sequence number, otherwise
|
||||
safety_assured do
|
||||
execute <<-SQL.squish
|
||||
execute <<~SQL.squish
|
||||
BEGIN;
|
||||
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;
|
||||
|
||||
@ -17,6 +17,7 @@ module Mastodon::CLI
|
||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||
option :verbose, type: :boolean, default: false, aliases: [:v]
|
||||
option :dry_run, type: :boolean, default: false
|
||||
option :keep_interacted, type: :boolean, default: false
|
||||
desc 'remove', 'Remove remote media files, headers or avatars'
|
||||
long_desc <<-DESC
|
||||
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
|
||||
the last webfinger request and update to the user has to be before they
|
||||
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 --remove-headers is specified, only headers are removed.
|
||||
If --include-follows is specified along with --prune-profiles or
|
||||
@ -61,7 +65,11 @@ module Mastodon::CLI
|
||||
end
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
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)
|
||||
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)
|
||||
@ -137,7 +137,7 @@ module Mastodon::CLI
|
||||
|
||||
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)
|
||||
SELECT id FROM conversations WHERE NOT EXISTS (SELECT 1 FROM statuses WHERE statuses.conversation_id = conversations.id)
|
||||
SQL
|
||||
|
||||
@ -73,6 +73,66 @@ RSpec.describe Mastodon::CLI::Media do
|
||||
expect(media_attachment.reload.thumbnail).to be_blank
|
||||
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
|
||||
|
||||
|
||||
@ -30,7 +30,8 @@ const dotenvFilePath = path.resolve(
|
||||
);
|
||||
|
||||
dotenv.config({
|
||||
path: dotenvFilePath
|
||||
path: dotenvFilePath,
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
initializeLogLevel(process.env, environment);
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv": "^17.0.0",
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsdom": "^27.0.0",
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@ -3007,7 +3007,7 @@ __metadata:
|
||||
"@types/ws": "npm:^8.5.9"
|
||||
bufferutil: "npm:^4.0.7"
|
||||
cors: "npm:^2.8.5"
|
||||
dotenv: "npm:^16.0.3"
|
||||
dotenv: "npm:^17.0.0"
|
||||
express: "npm:^5.1.0"
|
||||
globals: "npm:^17.0.0"
|
||||
ioredis: "npm:^5.3.2"
|
||||
@ -5545,13 +5545,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.4.0":
|
||||
version: 1.13.3
|
||||
resolution: "axios@npm:1.13.3"
|
||||
version: 1.13.4
|
||||
resolution: "axios@npm:1.13.4"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.4"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/86f0770624d9f14a3f8f8738c8b8f7f7fbb7b0d4ad38757db1de2d71007a0311bc597661c5ff4b4a9ee6350c6956a7282e3a281fcdf7b5b32054e35a8801e2ce
|
||||
checksum: 10c0/474c00b7d71f4de4ad562589dae6b615149df7c2583bbc5ebba96229f3f85bfb0775d23705338df072f12e48d3e85685c065a3cf6855d58968a672d19214c728
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -6643,13 +6643,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^16.0.3, dotenv@npm:^16.4.2":
|
||||
"dotenv@npm:^16.4.2":
|
||||
version: 16.6.1
|
||||
resolution: "dotenv@npm:16.6.1"
|
||||
checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.0.1
|
||||
resolution: "dunder-proto@npm:1.0.1"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user