Merge pull request #3454 from glitch-soc/glitch-soc/merge-upstream

Merge upstream changes up to 0ef43a431d
This commit is contained in:
Claire
2026-03-24 15:03:35 +01:00
committed by GitHub
110 changed files with 2352 additions and 1352 deletions

View File

@@ -3,7 +3,18 @@ import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
stories: [
{
directory: '../app/javascript/mastodon',
files: '**/*.stories.@(js|jsx|mjs|ts|tsx)',
titlePrefix: 'Vanilla',
},
{
directory: '../app/javascript/flavours/glitch',
files: '**/*.stories.@(js|jsx|mjs|ts|tsx)',
titlePrefix: 'Glitch',
},
],
addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',

View File

@@ -1,8 +0,0 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -68,6 +68,6 @@ The following table summarizes those limits.
| Account `attributionDomains` | 256 | List will be truncated |
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated |
| Media and avatar/header descriptions (`name`/`summary`) | 10000 | Description will be truncated |
| Collection name (`FeaturedCollection` `name`) | 256 | Name will be truncated |
| Collection description (`FeaturedCollection` `summary`) | 2048 | Description will be truncated |

View File

@@ -12,29 +12,29 @@ GEM
specs:
action_text-trix (2.1.17)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.2.1)
actionpack (= 8.1.2.1)
activesupport (= 8.1.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.2.1)
actionpack (= 8.1.2.1)
activejob (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.2.1)
actionpack (= 8.1.2.1)
actionview (= 8.1.2.1)
activejob (= 8.1.2.1)
activesupport (= 8.1.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.2.1)
actionview (= 8.1.2.1)
activesupport (= 8.1.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -42,16 +42,16 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.2.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.2.1)
activesupport (= 8.1.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@@ -61,22 +61,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.2.1)
activesupport (= 8.1.2.1)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.2.1)
activesupport (= 8.1.2.1)
activerecord (8.1.2.1)
activemodel (= 8.1.2.1)
activesupport (= 8.1.2.1)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.2.1)
actionpack (= 8.1.2.1)
activejob (= 8.1.2.1)
activerecord (= 8.1.2.1)
activesupport (= 8.1.2.1)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -251,12 +251,14 @@ GEM
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
flatware (2.3.4)
flatware (2.4.0)
benchmark
drb
logger
thor (< 2.0)
flatware-rspec (2.3.4)
flatware (= 2.3.4)
rspec (>= 3.6)
flatware-rspec (2.4.0)
flatware (= 2.4.0)
rspec (>= 3.8)
fog-core (2.6.0)
builder
excon (~> 1.0)
@@ -470,7 +472,7 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.1)
nokogiri (1.19.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
omniauth (2.1.4)
@@ -655,20 +657,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.2.1)
actioncable (= 8.1.2.1)
actionmailbox (= 8.1.2.1)
actionmailer (= 8.1.2.1)
actionpack (= 8.1.2.1)
actiontext (= 8.1.2.1)
actionview (= 8.1.2.1)
activejob (= 8.1.2.1)
activemodel (= 8.1.2.1)
activerecord (= 8.1.2.1)
activestorage (= 8.1.2.1)
activesupport (= 8.1.2.1)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -679,9 +681,9 @@ GEM
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.2.1)
actionpack (= 8.1.2.1)
activesupport (= 8.1.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)

View File

@@ -62,7 +62,11 @@ module Admin
end
def set_statuses
@statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
@statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(*preload_columns, reblog: [:account, *preload_columns]).page(params[:page]).per(PER_PAGE)
end
def preload_columns
[:application, :preloadable_poll, :media_attachments, active_mentions: :account]
end
def filter_params

View File

@@ -26,6 +26,12 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
profile_settings: {
'toot' => 'http://joinmastodon.org/ns#',
'showFeatured' => 'toot:showFeatured',
'showMedia' => 'toot:showMedia',
'showRepliesInMedia' => 'toot:showRepliesInMedia',
},
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',

View File

@@ -1,4 +1,4 @@
import type { FC, ReactNode } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import { useIntl } from 'react-intl';
@@ -19,7 +19,7 @@ import classes from './styles.module.css';
export interface CalloutProps {
variant?:
| 'default'
// | 'subtle'
| 'subtle'
| 'feature'
| 'inverted'
| 'success'
@@ -31,9 +31,9 @@ export interface CalloutProps {
/** Set to false to hide the icon. */
icon?: IconProp | boolean;
onPrimary?: () => void;
primaryLabel?: string;
primaryLabel?: string | ReactElement;
onSecondary?: () => void;
secondaryLabel?: string;
secondaryLabel?: string | ReactElement;
onClose?: () => void;
id?: string;
extraContent?: ReactNode;
@@ -41,7 +41,7 @@ export interface CalloutProps {
const variantClasses = {
default: classes.variantDefault as string,
// subtle: classes.variantSubtle as string,
subtle: classes.variantSubtle as string,
feature: classes.variantFeature as string,
inverted: classes.variantInverted as string,
success: classes.variantSuccess as string,

View File

@@ -32,6 +32,10 @@
.body {
flex-grow: 1;
a {
color: inherit;
}
h3 {
font-weight: 500;
margin-bottom: 5px;
@@ -51,6 +55,7 @@
color: inherit;
font-weight: 500;
padding: 0;
text-wrap: nowrap;
text-decoration: underline;
transition: color 0.1s ease-in-out;
@@ -80,14 +85,14 @@
}
}
/* .variantSubtle {
.variantSubtle {
border: 1px solid var(--color-bg-brand-softer);
background-color: var(--color-bg-primary);
.icon {
background-color: var(--color-bg-brand-softer);
}
} */
}
.variantFeature {
background-color: var(--color-bg-brand-base);

View File

@@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Combobox } from '@/flavours/glitch/components/form_fields';
import { useSearchTags } from '@/flavours/glitch/hooks/useSearchTags';
import type { TagSearchResult } from '@/flavours/glitch/hooks/useSearchTags';
import { addFeaturedTag } from '@/flavours/glitch/reducers/slices/profile_edit';
import { addFeaturedTags } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch } from '@/flavours/glitch/store';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@@ -47,7 +47,7 @@ export const AccountEditTagSearch: FC = () => {
(item: TagSearchResult) => {
resetSearch();
setQuery('');
void dispatch(addFeaturedTag({ name: item.name }));
void dispatch(addFeaturedTags({ names: [item.name] }));
},
[dispatch, resetSearch],
);

View File

@@ -9,7 +9,7 @@ import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import type { TagData } from '@/flavours/glitch/reducers/slices/profile_edit';
import {
addFeaturedTag,
addFeaturedTags,
deleteFeaturedTag,
fetchProfile,
fetchSuggestedTags,
@@ -128,7 +128,7 @@ const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
}) => {
const dispatch = useAppDispatch();
const handleAddTag = useCallback(() => {
void dispatch(addFeaturedTag({ name }));
void dispatch(addFeaturedTags({ names: [name] }));
}, [dispatch, name]);
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
};

View File

@@ -18,7 +18,10 @@ import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator
import { RemoteHint } from '@/flavours/glitch/components/remote_hint';
import StatusList from '@/flavours/glitch/components/status_list';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
import {
useAccountId,
useCurrentAccountId,
} from '@/flavours/glitch/hooks/useAccountId';
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
import { selectTimelineByKey } from '@/flavours/glitch/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
@@ -34,6 +37,7 @@ import {
usePinnedStatusIds,
} from './pinned_statuses';
import classes from './styles.module.scss';
import { TagSuggestions } from './tags_suggestions';
const emptyList = ImmutableList<string>();
@@ -135,6 +139,7 @@ const Prepend: FC<{
accountId: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId }) => {
const me = useCurrentAccountId();
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
@@ -144,6 +149,7 @@ const Prepend: FC<{
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
{me === accountId && <TagSuggestions />}
</>
);
};

View File

@@ -49,8 +49,12 @@
}
}
.tagsWrapper {
.tagsWrapper,
.tagSuggestions {
margin: 0 24px 8px;
}
.tagsWrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;

View File

@@ -0,0 +1,128 @@
import type { FC } from 'react';
import { useEffect, useCallback, useState } from 'react';
import { FormattedMessage, FormattedList } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchFeaturedTags } from '@/flavours/glitch/actions/featured_tags';
import { Callout } from '@/flavours/glitch/components/callout';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useDismissible } from '@/flavours/glitch/hooks/useDismissible';
import {
fetchProfile,
fetchSuggestedTags,
addFeaturedTags,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
import classes from './styles.module.scss';
const MAX_SUGGESTED_TAGS = 3;
export const TagSuggestions: FC = () => {
const { dismiss, wasDismissed } = useDismissible(
'profile/featured_tag_suggestions',
);
const suggestedTags = useAppSelector((state) =>
state.profileEdit.tagSuggestions?.slice(0, MAX_SUGGESTED_TAGS),
);
const existingTagCount = useAppSelector(
(state) => state.profileEdit.profile?.featuredTags.length,
);
const dispatch = useAppDispatch();
const isLoading = !suggestedTags || existingTagCount === undefined;
useEffect(() => {
if (isLoading) {
void dispatch(fetchProfile());
void dispatch(fetchSuggestedTags());
}
}, [dispatch, isLoading]);
const me = useCurrentAccountId();
const [showSuccessNotice, setSuccessNotice] = useState(false);
const handleAdd = useCallback(() => {
if (!suggestedTags?.length || !me) {
return;
}
const addTags = async () => {
await dispatch(
addFeaturedTags({ names: suggestedTags.map((tag) => tag.name) }),
);
await dispatch(fetchFeaturedTags({ accountId: me }));
setSuccessNotice(true);
dismiss();
};
void addTags();
}, [dismiss, dispatch, me, suggestedTags]);
const handleDismissSuccessNotice = useCallback(() => {
setSuccessNotice(false);
}, []);
if (showSuccessNotice) {
return (
<Callout
variant='subtle'
className={classes.tagSuggestions}
onClose={handleDismissSuccessNotice}
>
<FormattedMessage
id='featured_tags.suggestions.added'
defaultMessage='Manage your featured hashtags at any time under <link>Edit Profile > Featured hashtags</link>.'
values={{
link: (chunks) => <Link to='/profile/featured_tags'>{chunks}</Link>,
}}
/>
</Callout>
);
}
if (
isLoading ||
!suggestedTags.length ||
existingTagCount > 0 ||
wasDismissed
) {
return null;
}
return (
<Callout
id='featured_tags.suggestions'
variant='subtle'
className={classes.tagSuggestions}
onPrimary={handleAdd}
primaryLabel={
<FormattedMessage
id='featured_tags.suggestions.add'
defaultMessage='Add'
/>
}
onSecondary={dismiss}
secondaryLabel={
<FormattedMessage
id='featured_tags.suggestions.dismiss'
defaultMessage='No thanks'
/>
}
>
<FormattedMessage
id='featured_tags.suggestions'
defaultMessage='Lately youve posted about {items}. Add these as featured hashtags?'
values={{
items: (
<FormattedList
value={suggestedTags.map(({ name }) => `#${name}`)}
/>
),
}}
/>
</Callout>
);
};

View File

@@ -1,9 +1,4 @@
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import {
importCustomEmojiData,
importEmojiData,
importLegacyShortcodes,
} from './loader';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
@@ -16,6 +11,8 @@ function handleMessage(event: MessageEvent<{ locale: string }>) {
}
async function loadData(locale: string) {
const { importCustomEmojiData, importEmojiData, importLegacyShortcodes } =
await import('./loader');
let importCount: number | undefined;
if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length;

View File

@@ -104,24 +104,24 @@ const profileEditSlice = createSlice({
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => {
builder.addCase(addFeaturedTags.pending, (state) => {
state.isPending = true;
});
builder.addCase(addFeaturedTag.rejected, (state) => {
builder.addCase(addFeaturedTags.rejected, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
builder.addCase(addFeaturedTags.fulfilled, (state, action) => {
if (!state.profile) {
return;
}
state.profile.featuredTags = [
...state.profile.featuredTags,
transformTag(action.payload),
...action.payload.map(transformTag),
].toSorted((a, b) => a.name.localeCompare(b.name));
if (state.tagSuggestions) {
state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name,
(tag) => !action.meta.arg.names.includes(tag.name),
);
}
state.isPending = false;
@@ -350,20 +350,11 @@ export const fetchSuggestedTags = createDataLoadingThunk(
{ useLoadingBar: false },
);
export const addFeaturedTag = createDataLoadingThunk(
export const addFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/addFeaturedTag`,
({ name }: { name: string }) => apiPostFeaturedTag(name),
{
condition(arg, { getState }) {
const state = getState();
return (
!!state.profileEdit.profile &&
!state.profileEdit.profile.featuredTags.some(
(tag) => tag.name === arg.name,
)
);
},
},
({ names }: { names: string[] }) =>
Promise.all(names.map((n) => apiPostFeaturedTag(n))),
{ useLoadingBar: false },
);
export const deleteFeaturedTag = createDataLoadingThunk(

View File

@@ -1,4 +1,4 @@
import type { FC, ReactNode } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import { useIntl } from 'react-intl';
@@ -19,7 +19,7 @@ import classes from './styles.module.css';
export interface CalloutProps {
variant?:
| 'default'
// | 'subtle'
| 'subtle'
| 'feature'
| 'inverted'
| 'success'
@@ -31,9 +31,9 @@ export interface CalloutProps {
/** Set to false to hide the icon. */
icon?: IconProp | boolean;
onPrimary?: () => void;
primaryLabel?: string;
primaryLabel?: string | ReactElement;
onSecondary?: () => void;
secondaryLabel?: string;
secondaryLabel?: string | ReactElement;
onClose?: () => void;
id?: string;
extraContent?: ReactNode;
@@ -41,7 +41,7 @@ export interface CalloutProps {
const variantClasses = {
default: classes.variantDefault as string,
// subtle: classes.variantSubtle as string,
subtle: classes.variantSubtle as string,
feature: classes.variantFeature as string,
inverted: classes.variantInverted as string,
success: classes.variantSuccess as string,

View File

@@ -32,6 +32,10 @@
.body {
flex-grow: 1;
a {
color: inherit;
}
h3 {
font-weight: 500;
margin-bottom: 5px;
@@ -51,6 +55,7 @@
color: inherit;
font-weight: 500;
padding: 0;
text-wrap: nowrap;
text-decoration: underline;
transition: color 0.1s ease-in-out;
@@ -80,14 +85,14 @@
}
}
/* .variantSubtle {
.variantSubtle {
border: 1px solid var(--color-bg-brand-softer);
background-color: var(--color-bg-primary);
.icon {
background-color: var(--color-bg-brand-softer);
}
} */
}
.variantFeature {
background-color: var(--color-bg-brand-base);

View File

@@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Combobox } from '@/mastodon/components/form_fields';
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
import type { TagSearchResult } from '@/mastodon/hooks/useSearchTags';
import { addFeaturedTag } from '@/mastodon/reducers/slices/profile_edit';
import { addFeaturedTags } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch } from '@/mastodon/store';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@@ -47,7 +47,7 @@ export const AccountEditTagSearch: FC = () => {
(item: TagSearchResult) => {
resetSearch();
setQuery('');
void dispatch(addFeaturedTag({ name: item.name }));
void dispatch(addFeaturedTags({ names: [item.name] }));
},
[dispatch, resetSearch],
);

View File

@@ -9,7 +9,7 @@ import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
import {
addFeaturedTag,
addFeaturedTags,
deleteFeaturedTag,
fetchProfile,
fetchSuggestedTags,
@@ -128,7 +128,7 @@ const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
}) => {
const dispatch = useAppDispatch();
const handleAddTag = useCallback(() => {
void dispatch(addFeaturedTag({ name }));
void dispatch(addFeaturedTags({ names: [name] }));
}, [dispatch, name]);
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
};

View File

@@ -18,7 +18,10 @@ import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { RemoteHint } from '@/mastodon/components/remote_hint';
import StatusList from '@/mastodon/components/status_list';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import {
useAccountId,
useCurrentAccountId,
} from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -34,6 +37,7 @@ import {
usePinnedStatusIds,
} from './pinned_statuses';
import classes from './styles.module.scss';
import { TagSuggestions } from './tags_suggestions';
const emptyList = ImmutableList<string>();
@@ -135,6 +139,7 @@ const Prepend: FC<{
accountId: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId }) => {
const me = useCurrentAccountId();
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
@@ -144,6 +149,7 @@ const Prepend: FC<{
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
{me === accountId && <TagSuggestions />}
</>
);
};

View File

@@ -49,8 +49,12 @@
}
}
.tagsWrapper {
.tagsWrapper,
.tagSuggestions {
margin: 0 24px 8px;
}
.tagsWrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;

View File

@@ -0,0 +1,128 @@
import type { FC } from 'react';
import { useEffect, useCallback, useState } from 'react';
import { FormattedMessage, FormattedList } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
import { Callout } from '@/mastodon/components/callout';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import {
fetchProfile,
fetchSuggestedTags,
addFeaturedTags,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import classes from './styles.module.scss';
const MAX_SUGGESTED_TAGS = 3;
export const TagSuggestions: FC = () => {
const { dismiss, wasDismissed } = useDismissible(
'profile/featured_tag_suggestions',
);
const suggestedTags = useAppSelector((state) =>
state.profileEdit.tagSuggestions?.slice(0, MAX_SUGGESTED_TAGS),
);
const existingTagCount = useAppSelector(
(state) => state.profileEdit.profile?.featuredTags.length,
);
const dispatch = useAppDispatch();
const isLoading = !suggestedTags || existingTagCount === undefined;
useEffect(() => {
if (isLoading) {
void dispatch(fetchProfile());
void dispatch(fetchSuggestedTags());
}
}, [dispatch, isLoading]);
const me = useCurrentAccountId();
const [showSuccessNotice, setSuccessNotice] = useState(false);
const handleAdd = useCallback(() => {
if (!suggestedTags?.length || !me) {
return;
}
const addTags = async () => {
await dispatch(
addFeaturedTags({ names: suggestedTags.map((tag) => tag.name) }),
);
await dispatch(fetchFeaturedTags({ accountId: me }));
setSuccessNotice(true);
dismiss();
};
void addTags();
}, [dismiss, dispatch, me, suggestedTags]);
const handleDismissSuccessNotice = useCallback(() => {
setSuccessNotice(false);
}, []);
if (showSuccessNotice) {
return (
<Callout
variant='subtle'
className={classes.tagSuggestions}
onClose={handleDismissSuccessNotice}
>
<FormattedMessage
id='featured_tags.suggestions.added'
defaultMessage='Manage your featured hashtags at any time under <link>Edit Profile > Featured hashtags</link>.'
values={{
link: (chunks) => <Link to='/profile/featured_tags'>{chunks}</Link>,
}}
/>
</Callout>
);
}
if (
isLoading ||
!suggestedTags.length ||
existingTagCount > 0 ||
wasDismissed
) {
return null;
}
return (
<Callout
id='featured_tags.suggestions'
variant='subtle'
className={classes.tagSuggestions}
onPrimary={handleAdd}
primaryLabel={
<FormattedMessage
id='featured_tags.suggestions.add'
defaultMessage='Add'
/>
}
onSecondary={dismiss}
secondaryLabel={
<FormattedMessage
id='featured_tags.suggestions.dismiss'
defaultMessage='No thanks'
/>
}
>
<FormattedMessage
id='featured_tags.suggestions'
defaultMessage='Lately youve posted about {items}. Add these as featured hashtags?'
values={{
items: (
<FormattedList
value={suggestedTags.map(({ name }) => `#${name}`)}
/>
),
}}
/>
</Callout>
);
};

View File

@@ -1,9 +1,4 @@
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import {
importCustomEmojiData,
importEmojiData,
importLegacyShortcodes,
} from './loader';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
@@ -16,6 +11,8 @@ function handleMessage(event: MessageEvent<{ locale: string }>) {
}
async function loadData(locale: string) {
const { importCustomEmojiData, importEmojiData, importLegacyShortcodes } =
await import('./loader');
let importCount: number | undefined;
if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length;

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Дадайце да {count} уліковых запісаў, на якія Вы падпісаныя",
"collections.accounts.empty_title": "Гэтая калекцыя пустая",
"collections.collection_description": "Апісанне",
"collections.collection_language": "Мова",
"collections.collection_language_none": "Няма",
"collections.collection_name": "Назва",
"collections.collection_topic": "Тэма",
"collections.confirm_account_removal": "Упэўненыя, што хочаце прыбраць гэты ўліковы запіс з гэтай калекцыі?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural,one {Замацаваны допіс} other {Замацаваныя допісы}}",
"featured_carousel.slide": "Допіс {current, number} з {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Апошнім часам Вы рабілі допісы пра {items}. Дадаць гэта ў рэкамендаваныя хэштэгі?",
"featured_tags.suggestions.add": "Дадаць",
"featured_tags.suggestions.added": "Кіруйце сваімі рэкамендаванымі хэштэгамі ў любы час праз <link>Рэдагаваць профіль > Рэкамендаваныя хэштэгі</link>.",
"featured_tags.suggestions.dismiss": "Не, дзякуй",
"filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім Вы адкрылі гэты допіс. Калі Вы хочаце, каб паведамленне таксама было адфільтраванае ў гэтым кантэксце, Вам трэба будзе адрэдагаваць фільтр.",
"filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!",
"filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Tilføj op til {count} konti, du følger",
"collections.accounts.empty_title": "Denne samling er tom",
"collections.collection_description": "Beskrivelse",
"collections.collection_language": "Sprog",
"collections.collection_language_none": "Intet",
"collections.collection_name": "Navn",
"collections.collection_topic": "Emne",
"collections.confirm_account_removal": "Er du sikker på, at du vil fjerne denne konto fra denne samling?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {fastgjort indlæg} other {fastgjorte indlæg}}",
"featured_carousel.slide": "Indlæg {current, number} af {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Du har for nylig lagt indlæg op om {items}. Skal disse tilføjes som fremhævede hashtags?",
"featured_tags.suggestions.add": "Tilføj",
"featured_tags.suggestions.added": "Du kan til enhver tid administrere dine fremhævede hashtags under <link>Rediger profil > Fremhævede hashtags</link>.",
"featured_tags.suggestions.dismiss": "Nej tak",
"filter_modal.added.context_mismatch_explanation": "Denne filterkategori omfatter ikke konteksten, hvorunder dette indlæg er tilgået. Redigér filteret, hvis indlægget også ønskes filtreret i denne kontekst.",
"filter_modal.added.context_mismatch_title": "Kontekstmisforhold!",
"filter_modal.added.expired_explanation": "Denne filterkategori er udløbet. Ændr dens udløbsdato, for at anvende den.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Füge bis zu {count} Konten, denen du folgst, hinzu",
"collections.accounts.empty_title": "Diese Sammlung ist leer",
"collections.collection_description": "Beschreibung",
"collections.collection_language": "Sprache",
"collections.collection_language_none": "Nicht festgelegt",
"collections.collection_name": "Titel",
"collections.collection_topic": "Thema",
"collections.confirm_account_removal": "Möchtest du dieses Konto wirklich aus der Sammlung entfernen?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Angehefteter Beitrag} other {Angeheftete Beiträge}}",
"featured_carousel.slide": "Beitrag {current, number} von {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "In deinen letzten Beiträgen hast du {items} verwendet. Möchtest du sie zu deinen vorgestellten Hashtags hinzufügen?",
"featured_tags.suggestions.add": "Hinzufügen",
"featured_tags.suggestions.added": "Du kannst deine vorgestellten Hashtags jederzeit in den Einstellungen <link>Profil bearbeiten > Vorgestellte Hashtags</link> anpassen.",
"featured_tags.suggestions.dismiss": "Nein danke",
"filter_modal.added.context_mismatch_explanation": "Diese Filterkategorie gilt nicht für den Kontext, in welchem du auf diesen Beitrag zugegriffen hast. Wenn der Beitrag auch in diesem Kontext gefiltert werden soll, musst du den Filter bearbeiten.",
"filter_modal.added.context_mismatch_title": "Kontext stimmt nicht überein!",
"filter_modal.added.expired_explanation": "Diese Filterkategorie ist abgelaufen. Du musst das Ablaufdatum für diese Kategorie ändern.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Προσθέστε μέχρι και {count} λογαριασμούς που ακολουθείτε",
"collections.accounts.empty_title": "Αυτή η συλλογή είναι κενή",
"collections.collection_description": "Περιγραφή",
"collections.collection_language": "Γλώσσα",
"collections.collection_language_none": "Καμία",
"collections.collection_name": "Όνομα",
"collections.collection_topic": "Θέμα",
"collections.confirm_account_removal": "Σίγουρα θέλετε να αφαιρέσετε αυτόν τον λογαριασμό από αυτή τη συλλογή;",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Καρφιτσωμένη Ανάρτηση} other {Καρφιτσωμένες Αναρτήσεις}}",
"featured_carousel.slide": "Ανάρτηση {current, number} από {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Τον τελευταίο καιρό έχετε δημοσιεύσει για {items}. Προσθέστε αυτά ως αναδεδειγμένες ετικέτες;",
"featured_tags.suggestions.add": "Προσθήκη",
"featured_tags.suggestions.added": "Διαχειριστείτε τις αναδεδειγμένες ετικέτες σας οποιαδήποτε στιγμή κάτω από το <link>Επεξεργασία προφίλ > Αναδεδειγμένες ετικέτες</link>.",
"featured_tags.suggestions.dismiss": "Όχι, ευχαριστώ",
"filter_modal.added.context_mismatch_explanation": "Αυτή η κατηγορία φίλτρων δεν ισχύει για το περιεχόμενο εντός του οποίου προσπελάσατε αυτή την ανάρτηση. Αν θέλετε να φιλτραριστεί η ανάρτηση και εντός αυτού του πλαισίου, θα πρέπει να τροποποιήσετε το φίλτρο.",
"filter_modal.added.context_mismatch_title": "Ασυμφωνία περιεχομένου!",
"filter_modal.added.expired_explanation": "Αυτή η κατηγορία φίλτρων έχει λήξει, πρέπει να αλλάξετε την ημερομηνία λήξης για να ισχύσει.",

View File

@@ -628,6 +628,10 @@
"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}",
"featured_tags.suggestions": "Lately youve posted about {items}. Add these as featured hashtags?",
"featured_tags.suggestions.add": "Add",
"featured_tags.suggestions.added": "Manage your featured hashtags at any time under <link>Edit Profile > Featured hashtags</link>.",
"featured_tags.suggestions.dismiss": "No thanks",
"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.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Agregá hasta {count} cuentas que seguís",
"collections.accounts.empty_title": "Esta colección está vacía",
"collections.collection_description": "Descripción",
"collections.collection_language": "Idioma",
"collections.collection_language_none": "Ninguno",
"collections.collection_name": "Nombre",
"collections.collection_topic": "Tema",
"collections.confirm_account_removal": "¿De verdad querés eliminar esta cuenta de esta colección?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Mensaje fijado} other {Mensajes fijados}}",
"featured_carousel.slide": "Mensaje {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Recientemente, publicaste sobre {items}. ¿Querés agregarlas como etiquetas destacadas?",
"featured_tags.suggestions.add": "Agregar",
"featured_tags.suggestions.added": "Administrá tus etiquetas destacadas cuando quieras en <link>Editar perfil > Etiquetas destacadas</link>.",
"featured_tags.suggestions.dismiss": "No, gracias",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que accediste a este mensaje. Si querés que el mensaje sea filtrado también en este contexto, vas a tener que editar el filtro.",
"filter_modal.added.context_mismatch_title": "¡El contexto no coincide!",
"filter_modal.added.expired_explanation": "Esta categoría de filtro caducó; vas a necesitar cambiar la fecha de caducidad para que se aplique.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Añade hasta {count} cuentas que sigues",
"collections.accounts.empty_title": "Esta colección está vacía",
"collections.collection_description": "Descripción",
"collections.collection_language": "Idioma",
"collections.collection_language_none": "Ninguno",
"collections.collection_name": "Nombre",
"collections.collection_topic": "Tema",
"collections.confirm_account_removal": "¿Estás seguro/a de que quieres eliminar esta cuenta de esta colección?",
@@ -466,7 +468,7 @@
"confirmation_modal.cancel": "Cancelar",
"confirmations.block.confirm": "Bloquear",
"confirmations.delete.confirm": "Eliminar",
"confirmations.delete.message": "¿Estás seguro de que quieres borrar esta publicación?",
"confirmations.delete.message": "¿Estás seguro de que quieres eliminar esta publicación?",
"confirmations.delete.title": "¿Deseas eliminar la publicación?",
"confirmations.delete_collection.confirm": "Eliminar",
"confirmations.delete_collection.message": "Esta acción no se puede deshacer.",
@@ -506,9 +508,9 @@
"confirmations.quiet_post_quote_info.got_it": "Entendido",
"confirmations.quiet_post_quote_info.message": "Al citar una publicación pública discreta, tu publicación se ocultará de las cronologías de tendencias.",
"confirmations.quiet_post_quote_info.title": "Citar publicaciones públicas discretas",
"confirmations.redraft.confirm": "Borrar y volver a borrador",
"confirmations.redraft.message": "¿Estás seguro de que quieres borrar esta publicación y editarla? Los favoritos e impulsos se perderán, y las respuestas a la publicación original quedarán separadas.",
"confirmations.redraft.title": "¿Deseas borrar y volver a redactar la publicación?",
"confirmations.redraft.confirm": "Eliminar y volver a redactar",
"confirmations.redraft.message": "¿Estás seguro de que quieres eliminar esta publicación y volver a redactarla? Se perderán tanto los «Me gusta» como los impulsos, y las respuestas a la publicación original quedarán sin referencia.",
"confirmations.redraft.title": "¿Deseas eliminar y volver a redactar la publicación?",
"confirmations.remove_from_followers.confirm": "Eliminar seguidor",
"confirmations.remove_from_followers.message": "{name} dejará de seguirte. ¿Estás seguro de que quieres continuar?",
"confirmations.remove_from_followers.title": "¿Eliminar seguidor?",
@@ -527,7 +529,7 @@
"content_warning.hide": "Ocultar publicación",
"content_warning.show": "Mostrar de todos modos",
"content_warning.show_more": "Mostrar más",
"conversation.delete": "Borrar conversación",
"conversation.delete": "Eliminar conversación",
"conversation.mark_as_read": "Marcar como leído",
"conversation.open": "Ver conversación",
"conversation.with": "Con {names}",
@@ -570,7 +572,7 @@
"embed.instructions": "Añade esta publicación a tu sitio web con el siguiente código.",
"embed.preview": "Así es como se verá:",
"emoji_button.activity": "Actividad",
"emoji_button.clear": "Borrar",
"emoji_button.clear": "Eliminar",
"emoji_button.custom": "Personalizado",
"emoji_button.flags": "Marcas",
"emoji_button.food": "Comida y bebida",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural,one {Publicación fijada}other {Publicaciones fijadas}}",
"featured_carousel.slide": "Publicación {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Últimamente has publicado sobre {items}. ¿Deseas añadirlo como etiqueta destacada?",
"featured_tags.suggestions.add": "Añadir",
"featured_tags.suggestions.added": "Controla tus etiquetas destacadas en cualquier momento en <link>Editar perfil > Etiquetas destacadas</link>.",
"featured_tags.suggestions.dismiss": "No, gracias",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que has accedido a esta publicación. Si deseas que la publicación también se filtre en este contexto, tendrás que editar el filtro.",
"filter_modal.added.context_mismatch_title": "¡El contexto no coincide!",
"filter_modal.added.expired_explanation": "Esta categoría de filtro ha caducado; deberás cambiar la fecha de caducidad para que se aplique.",
@@ -798,7 +804,7 @@
"lists.create": "Crear",
"lists.create_a_list_to_organize": "Crea una nueva lista para organizar tu página de inicio",
"lists.create_list": "Crear lista",
"lists.delete": "Borrar lista",
"lists.delete": "Eliminar lista",
"lists.done": "Hecho",
"lists.edit": "Editar lista",
"lists.exclusive": "Ocultar miembros en Inicio",
@@ -928,7 +934,7 @@
"notification_requests.title": "Notificaciones filtradas",
"notification_requests.view": "Ver notificaciones",
"notifications.clear": "Limpiar notificaciones",
"notifications.clear_confirmation": "¿Seguro de querer borrar permanentemente todas tus notificaciones?",
"notifications.clear_confirmation": "¿Estás seguro de que quieres eliminar definitivamente todas tus notificaciones?",
"notifications.clear_title": "¿Limpiar notificaciones?",
"notifications.column_settings.admin.report": "Nuevas denuncias:",
"notifications.column_settings.admin.sign_up": "Registros nuevos:",
@@ -1162,7 +1168,7 @@
"status.context.show": "Mostrar",
"status.continued_thread": "Hilo continuado",
"status.copy": "Copiar enlace al estado",
"status.delete": "Borrar",
"status.delete": "Eliminar",
"status.delete.success": "Publicación eliminada",
"status.detailed_status": "Vista de conversación detallada",
"status.direct": "Mención privada @{name}",
@@ -1215,7 +1221,7 @@
"status.reblogged_by": "Impulsado por {name}",
"status.reblogs.empty": "Nadie impulsó esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
"status.reblogs_count": "{count, plural,one {{counter} impulso} other {{counter} impulsos}}",
"status.redraft": "Borrar y volver a borrador",
"status.redraft": "Eliminar y volver a redactar",
"status.remove_bookmark": "Eliminar marcador",
"status.remove_favourite": "Eliminar de favoritos",
"status.remove_quote": "Eliminar",

View File

@@ -626,6 +626,10 @@
"featured_carousel.header": "{count, plural,one {Publicación fijada} other {Publicaciones fijadas}}",
"featured_carousel.slide": "Publicación {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Últimamente has publicado sobre {items}. ¿Quieres añadirlo como etiqueta destacada?",
"featured_tags.suggestions.add": "Añadir",
"featured_tags.suggestions.added": "Controla tus etiquetas destacadas cuando quieras en <link>Editar perfil > Etiquetas destacadas</link>.",
"featured_tags.suggestions.dismiss": "No, gracias",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que ha accedido a esta publlicación. Si quieres que la publicación sea filtrada también en este contexto, tendrás que editar el filtro.",
"filter_modal.added.context_mismatch_title": "¡El contexto no coincide!",
"filter_modal.added.expired_explanation": "Esta categoría de filtro ha caducado, tendrás que cambiar la fecha de caducidad para que se aplique.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Lisää enintään {count} seuraamaasi tiliä",
"collections.accounts.empty_title": "Tämä kokoelma on tyhjä",
"collections.collection_description": "Kuvaus",
"collections.collection_language": "Kieli",
"collections.collection_language_none": "Ei mikään",
"collections.collection_name": "Nimi",
"collections.collection_topic": "Aihe",
"collections.confirm_account_removal": "Haluatko varmasti poistaa tämän tilin tästä kokoelmasta?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Kiinnitetty julkaisu} other {Kiinnitetyt julkaisut}}",
"featured_carousel.slide": "Julkaisu {current, number} / {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Olet viime aikoina julkaissut aiheista {items}. Lisätäänkö nämä esiteltäviksi aihetunnisteiksi?",
"featured_tags.suggestions.add": "Lisää",
"featured_tags.suggestions.added": "Hallitse esiteltäviä aihetunnisteitasi milloin tahansa kohdasta <link>Muokkaa profiilia > Esiteltävät aihetunnisteet</link>.",
"featured_tags.suggestions.dismiss": "Ei kiitos",
"filter_modal.added.context_mismatch_explanation": "Tämä suodatinluokka ei koske asiayhteyttä, jossa olet tarkastellut tätä julkaisua. Jos haluat julkaisun suodatettavan myös tässä asiayhteydessä, muokkaa suodatinta.",
"filter_modal.added.context_mismatch_title": "Asiayhteys ei täsmää!",
"filter_modal.added.expired_explanation": "Tämä suodatinluokka on vanhentunut, joten sinun on muutettava viimeistä voimassaolopäivää, jotta suodatusta käytettäisiin.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Ajouter jusqu'à {count} comptes que vous suivez",
"collections.accounts.empty_title": "Cette collection est vide",
"collections.collection_description": "Description",
"collections.collection_language": "Langue",
"collections.collection_language_none": "Aucune",
"collections.collection_name": "Nom",
"collections.collection_topic": "Sujet",
"collections.confirm_account_removal": "Voulez-vous vraiment supprimer ce compte de la collection?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
"featured_carousel.slide": "Message {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Récemment, vous avez posté à propos de {items}. Voulez-vous mettre ces hashtags en avant?",
"featured_tags.suggestions.add": "Ajouter",
"featured_tags.suggestions.added": "Gérer vos hashtags mis en avant à tout moment depuis <link>Modifier le profil > Hashtags mis en avant</link>.",
"featured_tags.suggestions.dismiss": "Non merci",
"filter_modal.added.context_mismatch_explanation": "Cette catégorie de filtre ne s'applique pas au contexte dans lequel vous avez accédé à cette publication. Si vous voulez que la publication soit filtrée dans ce contexte également, vous devrez modifier le filtre.",
"filter_modal.added.context_mismatch_title": "Incompatibilité du contexte!",
"filter_modal.added.expired_explanation": "Cette catégorie de filtre a expiré, vous devrez modifier la date d'expiration pour qu'elle soit appliquée.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Ajouter jusqu'à {count} comptes que vous suivez",
"collections.accounts.empty_title": "Cette collection est vide",
"collections.collection_description": "Description",
"collections.collection_language": "Langue",
"collections.collection_language_none": "Aucune",
"collections.collection_name": "Nom",
"collections.collection_topic": "Sujet",
"collections.confirm_account_removal": "Voulez-vous vraiment supprimer ce compte de la collection?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
"featured_carousel.slide": "Message {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Récemment, vous avez posté à propos de {items}. Voulez-vous mettre ces hashtags en avant?",
"featured_tags.suggestions.add": "Ajouter",
"featured_tags.suggestions.added": "Gérer vos hashtags mis en avant à tout moment depuis <link>Modifier le profil > Hashtags mis en avant</link>.",
"featured_tags.suggestions.dismiss": "Non merci",
"filter_modal.added.context_mismatch_explanation": "Cette catégorie de filtre ne s'applique pas au contexte dans lequel vous avez accédé à ce message. Si vous voulez que le message soit filtré dans ce contexte également, vous devrez modifier le filtre.",
"filter_modal.added.context_mismatch_title": "Incompatibilité du contexte !",
"filter_modal.added.expired_explanation": "Cette catégorie de filtre a expiré, vous devrez modifier la date d'expiration pour qu'elle soit appliquée.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Cuir suas le {count} cuntas leis a leanann tú",
"collections.accounts.empty_title": "Tá an bailiúchán seo folamh",
"collections.collection_description": "Cur síos",
"collections.collection_language": "Teanga",
"collections.collection_language_none": "Dada",
"collections.collection_name": "Ainm",
"collections.collection_topic": "Topaic",
"collections.confirm_account_removal": "An bhfuil tú cinnte gur mian leat an cuntas seo a bhaint den bhailiúchán seo?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Postáil phinnáilte} two {Poist Phionáilte} few {Poist Phionáilte} many {Poist Phionáilte} other {Poist Phionáilte}}",
"featured_carousel.slide": "Post {current, number} of {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Le déanaí, tá postáil déanta agat faoi {items}. An gcuirfeá iad seo leis mar hashtags le feiceáil?",
"featured_tags.suggestions.add": "Cuir leis",
"featured_tags.suggestions.added": "Bainistigh do hashtags le feiceáil ag am ar bith faoi <link>Cuir Próifíl in Eagar > Hashtags le feiceáil</link>.",
"featured_tags.suggestions.dismiss": "Ní raibh maith agat",
"filter_modal.added.context_mismatch_explanation": "Ní bhaineann an chatagóir scagaire seo leis an gcomhthéacs ina bhfuair tú rochtain ar an bpostáil seo. Más mian leat an postáil a scagadh sa chomhthéacs seo freisin, beidh ort an scagaire a chur in eagar.",
"filter_modal.added.context_mismatch_title": "Neamhréir comhthéacs!",
"filter_modal.added.expired_explanation": "Tá an chatagóir scagaire seo imithe in éag, beidh ort an dáta éaga a athrú chun é a chur i bhfeidhm.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Engade ata {count} contas que segues",
"collections.accounts.empty_title": "A colección está baleira",
"collections.collection_description": "Descrición",
"collections.collection_language": "Idioma",
"collections.collection_language_none": "Ningún",
"collections.collection_name": "Nome",
"collections.collection_topic": "Temática",
"collections.confirm_account_removal": "Tes certeza de querer retirar esta conta desta colección?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Publicación fixada} other {Publicacións fixadas}}",
"featured_carousel.slide": "Publicación {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Ultimamente publicaches sobre {items}. Engadimos isto como cancelos destacados?",
"featured_tags.suggestions.add": "Engadir",
"featured_tags.suggestions.added": "Xestiona os teus cancelos destacados cando queiras en <link>Editar perfil > Cancelos destacados</link>.",
"featured_tags.suggestions.dismiss": "Non, grazas",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro non se aplica ao contexto no que accedeches a esta publicación. Se queres que a publicación se filtre nese contexto tamén, terás que editar o filtro.",
"filter_modal.added.context_mismatch_title": "Non concorda o contexto!",
"filter_modal.added.expired_explanation": "Esta categoría de filtro caducou, terás que cambiar a data de caducidade para que se aplique.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "להוסיף עד ל־{count} חשבונות שאתם עוקבים אחריהם",
"collections.accounts.empty_title": "האוסף הזה ריק",
"collections.collection_description": "תיאור",
"collections.collection_language": "שפה",
"collections.collection_language_none": "לא מצוין",
"collections.collection_name": "כינוי",
"collections.collection_topic": "נושא",
"collections.confirm_account_removal": "בוודאות להסיר חשבון זה מהאוסף?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {הודעה אחת נעוצה} two {הודעותיים נעוצות} many {הודעות נעוצות} other {הודעות נעוצות}}",
"featured_carousel.slide": "הודעה {current, number} מתוך {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "לאחרונה פרסמת על {items}. להוסיף את הנושאים לתגיות הנבחרות?",
"featured_tags.suggestions.add": "הוספה",
"featured_tags.suggestions.added": "ניהול התגיות הנבחרות בכל עת תחת <link>עריכת פרופיל > תגיות נבחרות</link>.",
"featured_tags.suggestions.dismiss": "לא תודה",
"filter_modal.added.context_mismatch_explanation": "קטגוריית הסנן הזאת לא חלה על ההקשר שממנו הגעת אל ההודעה הזו. אם תרצה/י שההודעה תסונן גם בהקשר זה, תצטרך/י לערוך את הסנן.",
"filter_modal.added.context_mismatch_title": "אין התאמה להקשר!",
"filter_modal.added.expired_explanation": "פג תוקפה של קטגוריית הסינון הזו, יש צורך לשנות את תאריך התפוגה כדי שהסינון יוחל.",

View File

@@ -348,6 +348,8 @@
"collections.accounts.empty_description": "Adj hozzá legfeljebb {count} követett fiókot",
"collections.accounts.empty_title": "Ez a gyűjtemény üres",
"collections.collection_description": "Leírás",
"collections.collection_language": "Nyelv",
"collections.collection_language_none": "Egyik sem",
"collections.collection_name": "Név",
"collections.collection_topic": "Téma",
"collections.confirm_account_removal": "Biztos, hogy eltávolítod ezt a fiókot ebből a gyűjteményből?",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Bættu við allt að {count} aðgöngum sem þú fylgist með",
"collections.accounts.empty_title": "Þetta safn er tómt",
"collections.collection_description": "Lýsing",
"collections.collection_language": "Tungumál",
"collections.collection_language_none": "Ekkert",
"collections.collection_name": "Nafn",
"collections.collection_topic": "Umfjöllunarefni",
"collections.confirm_account_removal": "Ertu viss um að þú viljir fjarlægja þennan aðgang úr þessu safni?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Fest færsla} other {Festar færslur}}",
"featured_carousel.slide": "Færsla {current, number} af {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Að undanförnu hefurðu skrifað um {items}. Ætti að bæta þessu við sem myllumerkjum með aukið vægi?",
"featured_tags.suggestions.add": "Bæta við",
"featured_tags.suggestions.added": "Sýslaðu með þau myllumerki þín sem eru með með aukið vægi með því að fara í <link>Breyta notandasniði > Myllumerki með aukið vægi</link>.",
"featured_tags.suggestions.dismiss": "Nei takk",
"filter_modal.added.context_mismatch_explanation": "Þessi síuflokkur á ekki við í því samhengi sem aðgangur þinn að þessari færslu felur í sér. Ef þú vilt að færslan sé einnig síuð í þessu samhengi, þá þarftu að breyta síunni.",
"filter_modal.added.context_mismatch_title": "Misræmi í samhengi!",
"filter_modal.added.expired_explanation": "Þessi síuflokkur er útrunninn, þú þarft að breyta gidistímanum svo hann geti átt við.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Aggiungi fino a {count} account che segui",
"collections.accounts.empty_title": "Questa collezione è vuota",
"collections.collection_description": "Descrizione",
"collections.collection_language": "Lingua",
"collections.collection_language_none": "Nessuna",
"collections.collection_name": "Nome",
"collections.collection_topic": "Argomento",
"collections.confirm_account_removal": "Si è sicuri di voler rimuovere questo account da questa collezione?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Post appuntato} other {Post appuntati}}",
"featured_carousel.slide": "Post {current, number} di {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Ultimamente hai pubblicato contenuti relativi a {items}. Vuoi aggiungerli come hashtag in evidenza?",
"featured_tags.suggestions.add": "Aggiungi",
"featured_tags.suggestions.added": "Gestisci i tuoi hashtag in evidenza in qualsiasi momento in <link>Modifica profilo > Hashtag in evidenza</link>.",
"featured_tags.suggestions.dismiss": "No, grazie",
"filter_modal.added.context_mismatch_explanation": "La categoria di questo filtro non si applica al contesto in cui hai acceduto a questo post. Se desideri che il post sia filtrato anche in questo contesto, dovrai modificare il filtro.",
"filter_modal.added.context_mismatch_title": "Contesto non corrispondente!",
"filter_modal.added.expired_explanation": "La categoria di questo filtro è scaduta, dovrvai modificarne la data di scadenza per applicarlo.",

View File

@@ -162,7 +162,7 @@
"account_edit.featured_hashtags.item": "hashtag",
"account_edit.featured_hashtags.placeholder": "幫tsān別lâng認捌kap緊緊接近使用lí收藏ê主題。",
"account_edit.featured_hashtags.title": "特色ê hashtag",
"account_edit.field_delete_modal.confirm": "Lí敢確定behthâi掉tsit ê自訂ê框áTsit ê動作bē當改倒轉。",
"account_edit.field_delete_modal.confirm": "Lí敢確定beh thâi掉tsit ê自訂ê框áTsit ê動作bē當改倒轉。",
"account_edit.field_delete_modal.delete_button": "Thâi掉",
"account_edit.field_delete_modal.title": "敢beh thâi掉自訂ê框á",
"account_edit.field_edit_modal.add_title": "加自訂ê框á",
@@ -183,6 +183,15 @@
"account_edit.field_reorder_modal.drag_start": "有揀框á「{item}」。",
"account_edit.field_reorder_modal.handle_label": "Giú框á「{item}」",
"account_edit.field_reorder_modal.title": "重整理框á",
"account_edit.image_alt_modal.add_title": "加添說明文字",
"account_edit.image_alt_modal.details_content": "著愛:<ul> <li>照圖描述家kī</li> <li>用第三人稱(比如用「阿明」,毋是「我」)</li> <li>束結幾个詞就夠ah</li> </ul> 毋通:<ul> <li>用「……ê相片」結束,這對讀螢幕程式是加講ê</li> </ul> 例:<ul> <li>「阿明穿青ê siá-tsuhkoh掛眼鏡」</li> </ul>",
"account_edit.image_alt_modal.details_title": "撇步:個人資料ê相片 ê 說明文字",
"account_edit.image_alt_modal.edit_title": "編說明文字",
"account_edit.image_alt_modal.text_hint": "ALT說明文字通幫tsān讀螢幕程式ê用者知影lí ê內容。",
"account_edit.image_alt_modal.text_label": "替代文字",
"account_edit.image_delete_modal.confirm": "Lí敢確定beh thâi掉tsit幅圖Tsit ê動作bē當改倒轉。",
"account_edit.image_delete_modal.delete_button": "Thâi掉",
"account_edit.image_delete_modal.title": "Kám beh thâi掉影像",
"account_edit.image_edit.add_button": "加圖片",
"account_edit.image_edit.alt_add_button": "加添說明文字",
"account_edit.image_edit.alt_edit_button": "編說明文字",
@@ -202,6 +211,16 @@
"account_edit.profile_tab.subtitle": "自訂lí ê個人資料ê分頁kap顯示ê內容。",
"account_edit.profile_tab.title": "個人資料分頁設定",
"account_edit.save": "儲存",
"account_edit.upload_modal.back": "轉去",
"account_edit.upload_modal.done": "做好ah",
"account_edit.upload_modal.next": "後一步",
"account_edit.upload_modal.step_crop.zoom": "伸kiu",
"account_edit.upload_modal.step_upload.button": "瀏覽檔案",
"account_edit.upload_modal.step_upload.dragging": "Giú kàu tsia傳上去",
"account_edit.upload_modal.step_upload.header": "揀圖片",
"account_edit.upload_modal.step_upload.hint": "WEBP、PNG、GIF á是 JPG 格式,上大 {limit}MB。{br}圖會伸kiu kàu {width}x{height} px。",
"account_edit.upload_modal.title_add": "加個人資料ê相",
"account_edit.upload_modal.title_replace": "替換個人資料ê相",
"account_edit.verified_modal.details": "用驗證連kàu個人網站ê連結來加添lí ê Mastodon個人檔案ê通信ê程度。下kha是運作ê方法",
"account_edit.verified_modal.invisible_link.details": "加連結kàu lí ê網頁頭(header)。上重要ê部份是 rel=\"me\"伊防止通過用者生成ê網站內容來做假包。Lí甚至佇網頁ê header毋免用 {tag}反轉用link標簽但是HTML定著佇無執行JavaScript ê時陣,就ē當接近使用。",
"account_edit.verified_modal.invisible_link.summary": "Án-tsuánn khàm掉tsit ê連結?",
@@ -330,6 +349,8 @@
"collections.accounts.empty_description": "加lí跟tuè ê口座上tsē {count} ê",
"collections.accounts.empty_title": "收藏內底無半項",
"collections.collection_description": "說明",
"collections.collection_language": "語言",
"collections.collection_language_none": "無",
"collections.collection_name": "名",
"collections.collection_topic": "主題",
"collections.confirm_account_removal": "Lí確定beh對收藏suá掉tsit ê口座?",
@@ -488,7 +509,7 @@
"confirmations.quiet_post_quote_info.message": "Nā是引用無tī公共時間線內底êPO文lí êPO文bē當tī趨勢ê時間線顯示。",
"confirmations.quiet_post_quote_info.title": "引用無tī公開ê時間線內底顯示ê PO文",
"confirmations.redraft.confirm": "Thâi掉了後重寫",
"confirmations.redraft.message": "Lí kám確定behthâi掉tsit篇PO文了後koh重寫收藏kap轉PO ē無去,而且原底ê PO文ê回應ē變孤立。",
"confirmations.redraft.message": "Lí kám確定beh thâi掉tsit篇PO文了後koh重寫收藏kap轉PO ē無去,而且原底ê PO文ê回應ē變孤立。",
"confirmations.redraft.title": "Kám beh thâi掉koh重寫PO文",
"confirmations.remove_from_followers.confirm": "Suá掉跟tuè lí ê",
"confirmations.remove_from_followers.message": "{name} ē停止跟tuè lí。Lí kám確定beh繼續",

View File

@@ -153,7 +153,7 @@
"account_edit.custom_fields.name": "veld",
"account_edit.custom_fields.placeholder": "Voeg je voornaamwoorden, externe links of iets anders toe dat je wilt delen.",
"account_edit.custom_fields.reorder_button": "Velden opnieuw ordenen",
"account_edit.custom_fields.tip_content": "Je kunt gemakkelijk geloofwaardigheid toevoegen aan je Mastodon account door links te controleren naar websites die je bezit.",
"account_edit.custom_fields.tip_content": "Je kunt gemakkelijk je Mastodon-account geloofwaardig maken door links naar websites die van jou zijn te laten verifiëren.",
"account_edit.custom_fields.tip_title": "Tip: Geverifieerde links toevoegen",
"account_edit.custom_fields.title": "Extra velden",
"account_edit.custom_fields.verified_hint": "Hoe voeg ik een geverifieerde link toe?",
@@ -179,15 +179,15 @@
"account_edit.field_reorder_modal.drag_end": "Veld \"{item}\" werd geschrapt.",
"account_edit.field_reorder_modal.drag_instructions": "Druk op spatie of enter om de aangepaste velden te herschikken. Gebruik de pijltjestoetsen om het veld omhoog of omlaag te verplaatsen. Druk opnieuw op spatie of enter om het veld op diens nieuwe positie te laten vallen, of druk op escape om te annuleren.",
"account_edit.field_reorder_modal.drag_move": "Veld \"{item}\" is verplaatst.",
"account_edit.field_reorder_modal.drag_over": "Veld \"{item}\" is over \"{over} \" verplaatst.",
"account_edit.field_reorder_modal.drag_over": "Veld \"{item}\" is over \"{over}\" geplaatst.",
"account_edit.field_reorder_modal.drag_start": "Opgepakt veld \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Veld \"{item}\" slepen",
"account_edit.field_reorder_modal.title": "Velden herschikken",
"account_edit.image_alt_modal.add_title": "Alt-tekst toevoegen",
"account_edit.image_alt_modal.details_content": "DOEN: <ul> <li>Beschrijf jezelf zoals afgebeeld</li> <li>Gebruik taal van een derde persoon (bijv. “Alex” in plaats van “ik”)</li> <li>Wees beknopt - een paar woorden is vaak genoeg</li> </ul> NIET DOEN: <ul> <li>Begin met “Foto van” - dit is overbodig voor schermlezers</li> </ul> VOORBEELD: <ul> <li>“Alex draagt een groen shirt en een bril”</li> </ul>",
"account_edit.image_alt_modal.details_content": "DOEN: <ul> <li>Beschrijf jezelf zoals afgebeeld</li> <li>Schrijf in de derde persoon (bijv. “Alex” in plaats van “ik”)</li> <li>Wees beknopt - een paar woorden is vaak genoeg</li> </ul> NIET DOEN: <ul> <li>Begin met “Foto van” - dit is overbodig voor schermlezers</li> </ul> VOORBEELD: <ul> <li>“Alex draagt een groen shirt en een bril”</li> </ul>",
"account_edit.image_alt_modal.details_title": "Tips: Alt-tekst voor profielfoto's",
"account_edit.image_alt_modal.edit_title": "Alt-tekst bewerken",
"account_edit.image_alt_modal.text_hint": "Alt-text helpt gebruikers van schermlezer jouw inhoud te begrijpen.",
"account_edit.image_alt_modal.text_hint": "Alt-text helpt gebruikers van schermlezers jouw inhoud te begrijpen.",
"account_edit.image_alt_modal.text_label": "Alt-tekst",
"account_edit.image_delete_modal.confirm": "Weet je zeker dat je deze afbeelding wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"account_edit.image_delete_modal.delete_button": "Verwijderen",
@@ -221,10 +221,10 @@
"account_edit.upload_modal.step_upload.hint": "WEBP-, PNG-, GIF- of JPG-formaat, tot max. {limit}MB.{br}Afbeelding wordt geschaald naar {width}x{height}px.",
"account_edit.upload_modal.title_add": "Profielfoto toevoegen",
"account_edit.upload_modal.title_replace": "Profielfoto vervangen",
"account_edit.verified_modal.details": "Voeg geloofwaardigheid toe aan je Mastodonprofiel door links naar persoonlijke websites te verifiëren. Zo werkt het:",
"account_edit.verified_modal.invisible_link.details": "Voeg de link toe aan uw header. Het belangrijke onderdeel is rel=\"me\" om te voorkomen dat websites impersoneren met door de gebruiker gegenereerde inhoud. Je kunt zelfs een linktag gebruiken in de kop van de pagina in plaats van {tag}, maar de HTML moet toegankelijk zijn zonder JavaScript uit te voeren.",
"account_edit.verified_modal.details": "Maak je Mastodonprofiel geloofwaardig door links naar persoonlijke websites te verifiëren. Zo werkt het:",
"account_edit.verified_modal.invisible_link.details": "Voeg de link aan de HTML van je website toe. Het belangrijkste onderdeel is rel=\"me\", waarmee wordt voorkomen dat websites met user-generated content geïmpersoneerd kunnen worden. Je kunt zelfs een <link>-tag gebruiken binnen de <head>-tag van je website in plaats van {tag}, maar de HTML moet zonder JavaScript toegankelijk zijn.",
"account_edit.verified_modal.invisible_link.summary": "Hoe maak ik de link onzichtbaar?",
"account_edit.verified_modal.step1.header": "Kopieer de onderstaande HTML-code en plak deze in de koptekst van je website",
"account_edit.verified_modal.step1.header": "Kopieer de onderstaande HTML-code en plak deze binnen de <head>-tag van je website",
"account_edit.verified_modal.step2.details": "Als je je website al als een aangepast veld hebt toegevoegd, moet je deze verwijderen en opnieuw toevoegen om de verificatie te activeren.",
"account_edit.verified_modal.step2.header": "Voeg je website toe als een aangepast veld",
"account_edit.verified_modal.title": "Hoe voeg je een geverifieerde link toe",
@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Tot {count} accounts die je volgt toevoegen",
"collections.accounts.empty_title": "Deze verzameling is leeg",
"collections.collection_description": "Omschrijving",
"collections.collection_language": "Taal",
"collections.collection_language_none": "Geen",
"collections.collection_name": "Naam",
"collections.collection_topic": "Onderwerp",
"collections.confirm_account_removal": "Weet je zeker dat je dit account uit deze verzameling wilt verwijderen?",

View File

@@ -344,6 +344,8 @@
"collections.account_count": "{count, plural, one {# llogari} other {# llogari}}",
"collections.accounts.empty_title": "Ky koleksion është i zbrazët",
"collections.collection_description": "Përshkrim",
"collections.collection_language": "Gjuhë",
"collections.collection_language_none": "Asnjë",
"collections.collection_name": "Emër",
"collections.collection_topic": "Temë",
"collections.confirm_account_removal": "Jeni i sigurt se doni të hiqet kjo llogari nga ky koleksion?",

View File

@@ -277,6 +277,8 @@
"closed_registrations_modal.preamble": "Mastodon är decentraliserat så oavsett var du skapar ditt konto kommer du att kunna följa och interagera med någon på denna server. Du kan också köra din egen server!",
"closed_registrations_modal.title": "Registrera sig på Mastodon",
"collections.accounts.empty_description": "Lägg till upp till {count} konton som du följer",
"collections.collection_language": "Språk",
"collections.collection_language_none": "Inga",
"collections.create_a_collection_hint": "Skapa en samling för att rekommendera eller dela dina favoritkonton med andra.",
"collections.create_collection": "Skapa samling",
"collections.delete_collection": "Radera samling",
@@ -501,6 +503,10 @@
"featured_carousel.current": "<sr>Inlägg</sr> {current, number} / {max, number}",
"featured_carousel.header": "{count, plural,one {Fäst inlägg} other {Fästa inlägg}}",
"featured_carousel.slide": "Inlägg {current, number} av {max, number}",
"featured_tags.suggestions": "På senare tid har du skrivit om {items}. Lägg till dessa som utvalda hashtaggar?",
"featured_tags.suggestions.add": "Lägg till",
"featured_tags.suggestions.added": "Hantera dina utvalda hashtaggar när som helst under <link>Redigera profil > Utvalda hashtaggar</link>.",
"featured_tags.suggestions.dismiss": "Nej tack",
"filter_modal.added.context_mismatch_explanation": "Denna filterkategori gäller inte för det sammanhang där du har tillgång till det här inlägget. Om du vill att inlägget ska filtreras även i detta sammanhang måste du redigera filtret.",
"filter_modal.added.context_mismatch_title": "Misspassning av sammanhang!",
"filter_modal.added.expired_explanation": "Denna filterkategori har utgått, du måste ändra utgångsdatum för att den ska kunna tillämpas.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Takip ettiğiniz hesapların sayısını {count} kadar artırın",
"collections.accounts.empty_title": "Bu koleksiyon boş",
"collections.collection_description": "Açıklama",
"collections.collection_language": "Dil",
"collections.collection_language_none": "Hiçbiri",
"collections.collection_name": "Ad",
"collections.collection_topic": "Konu",
"collections.confirm_account_removal": "Bu hesabı bu koleksiyondan çıkarmak istediğinizden emin misiniz?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {{counter} Sabitlenmiş Gönderi} other {{counter} Sabitlenmiş Gönderi}}",
"featured_carousel.slide": "Gönderi {current, number} / {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Son zamanlarda {items} hakkında gönderileriniz var. Bunlar öne çıkan etiketler olarak eklensin mi?",
"featured_tags.suggestions.add": "Ekle",
"featured_tags.suggestions.added": "Öne çıkan etiketlerinizi istediğiniz zaman <link>Profil Düzenle > Öne çıkan etiketler</link> bölümünden yönetebilirsiniz.",
"featured_tags.suggestions.dismiss": "Hayır teşekkürler",
"filter_modal.added.context_mismatch_explanation": "Bu süzgeç kategorisi, bu gönderide eriştiğin bağlama uymuyor. Eğer gönderinin bu bağlamda da filtrelenmesini istiyorsanız, süzgeci düzenlemeniz gerekiyor.",
"filter_modal.added.context_mismatch_title": "Bağlam uyumsuzluğu!",
"filter_modal.added.expired_explanation": "Bu süzgeç kategorisinin süresi dolmuş, süzgeci uygulamak için bitiş tarihini değiştirmeniz gerekiyor.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "Thêm tối đa {count} tài khoản mà bạn theo dõi",
"collections.accounts.empty_title": "Gói khởi đầu này trống",
"collections.collection_description": "Mô tả",
"collections.collection_language": "Ngôn ngữ",
"collections.collection_language_none": "Không",
"collections.collection_name": "Tên",
"collections.collection_topic": "Chủ đề",
"collections.confirm_account_removal": "Bạn có chắc muốn gỡ tài khoản này khỏi gói khởi đầu?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, other {Tút đã ghim}}",
"featured_carousel.slide": "Tút {current, number} trong {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Gần đây bạn đã đăng về {items}. Thêm cái này vào hashtag thường dùng?",
"featured_tags.suggestions.add": "Thêm",
"featured_tags.suggestions.added": "Quản lý hashtag bạn thường dùng vào bất cứ lúc nào tại <link>Sửa hồ sơ > Hashtag thường dùng</link>.",
"featured_tags.suggestions.dismiss": "Không, cảm ơn",
"filter_modal.added.context_mismatch_explanation": "Danh mục bộ lọc này không áp dụng cho ngữ cảnh mà bạn đã truy cập tút này. Nếu bạn muốn tút cũng được lọc trong ngữ cảnh này, bạn sẽ phải chỉnh sửa bộ lọc.",
"filter_modal.added.context_mismatch_title": "Bối cảnh không phù hợp!",
"filter_modal.added.expired_explanation": "Danh mục bộ lọc này đã hết hạn, bạn sẽ cần thay đổi ngày hết hạn để áp dụng.",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "添加你关注的账号,最多 {count} 个",
"collections.accounts.empty_title": "收藏列表为空",
"collections.collection_description": "说明",
"collections.collection_language": "语言",
"collections.collection_language_none": "无",
"collections.collection_name": "名称",
"collections.collection_topic": "话题",
"collections.confirm_account_removal": "你确定要将从收藏列表中移除此账号吗?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, other {# 条置顶嘟文}}",
"featured_carousel.slide": "第 {current, number} 条嘟文,共 {max, number} 条",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "你最近发布了有关{items}的内容。要将这些添加为精选的话题标签吗?",
"featured_tags.suggestions.add": "添加",
"featured_tags.suggestions.added": "你可以在<link>修改个人资料 > 精选的话题标签</link>下随时管理你精选的话题标签。",
"featured_tags.suggestions.dismiss": "不了,谢谢",
"filter_modal.added.context_mismatch_explanation": "这条过滤规则不适用于你当前访问此嘟文的场景。要在此场景下过滤嘟文,你必须编辑此过滤规则。",
"filter_modal.added.context_mismatch_title": "场景不匹配!",
"filter_modal.added.expired_explanation": "此过滤规则类别已过期,你需要修改到期日期才能应用。",

View File

@@ -349,6 +349,8 @@
"collections.accounts.empty_description": "加入最多 {count} 個您跟隨之帳號",
"collections.accounts.empty_title": "此收藏名單是空的",
"collections.collection_description": "說明",
"collections.collection_language": "語言",
"collections.collection_language_none": "無",
"collections.collection_name": "名稱",
"collections.collection_topic": "主題",
"collections.confirm_account_removal": "您是否確定要自此收藏名單中移除此帳號?",
@@ -626,6 +628,10 @@
"featured_carousel.header": "{count, plural, other {# 則釘選嘟文}}",
"featured_carousel.slide": "{max, number} 則嘟文中之第 {current, number} 則",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "最近您曾發有關 {items} 之嘟文。是否新增其為推薦主題標籤?",
"featured_tags.suggestions.add": "新增",
"featured_tags.suggestions.added": "於 <link>編輯個人檔案 > 推薦主題標籤</link> 隨時管理您的推薦主題標籤。",
"featured_tags.suggestions.dismiss": "不需要,謝謝",
"filter_modal.added.context_mismatch_explanation": "此過濾器類別不是用您所存取嘟文的情境。若您想要此嘟文被於此情境被過濾,您必須編輯過濾器。",
"filter_modal.added.context_mismatch_title": "不符合情境!",
"filter_modal.added.expired_explanation": "此過濾器類別已失效,您需要更新過期日期以套用。",

View File

@@ -104,24 +104,24 @@ const profileEditSlice = createSlice({
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => {
builder.addCase(addFeaturedTags.pending, (state) => {
state.isPending = true;
});
builder.addCase(addFeaturedTag.rejected, (state) => {
builder.addCase(addFeaturedTags.rejected, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
builder.addCase(addFeaturedTags.fulfilled, (state, action) => {
if (!state.profile) {
return;
}
state.profile.featuredTags = [
...state.profile.featuredTags,
transformTag(action.payload),
...action.payload.map(transformTag),
].toSorted((a, b) => a.name.localeCompare(b.name));
if (state.tagSuggestions) {
state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name,
(tag) => !action.meta.arg.names.includes(tag.name),
);
}
state.isPending = false;
@@ -350,20 +350,11 @@ export const fetchSuggestedTags = createDataLoadingThunk(
{ useLoadingBar: false },
);
export const addFeaturedTag = createDataLoadingThunk(
export const addFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/addFeaturedTag`,
({ name }: { name: string }) => apiPostFeaturedTag(name),
{
condition(arg, { getState }) {
const state = getState();
return (
!!state.profileEdit.profile &&
!state.profileEdit.profile.featuredTags.some(
(tag) => tag.name === arg.name,
)
);
},
},
({ names }: { names: string[] }) =>
Promise.all(names.map((n) => apiPostFeaturedTag(n))),
{ useLoadingBar: false },
);
export const deleteFeaturedTag = createDataLoadingThunk(

View File

@@ -51,13 +51,17 @@ export const mockHandlers = {
'/packs-dev/emoji/:locale.json',
async ({ params }) => {
const locale = toSupportedLocale(params.locale);
const key = `../../../../../node_modules/emojibase-data/${locale}/compact.json`;
const emojiModules = import.meta.glob<CompactEmoji[]>(
'../../../../../node_modules/emojibase-data/**/compact.json',
{ import: 'default' },
);
const path = emojiModules[key];
if (!path) {
throw new Error(`Unsupported locale: ${locale}`);
}
action('fetching emoji data')(locale);
const { default: data } = (await import(
/* @vite-ignore */
`emojibase-data/${locale}/compact.json`
)) as {
default: CompactEmoji[];
};
const data = await path();
return HttpResponse.json([data]);
},

View File

@@ -42,6 +42,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status
@tags = []
@mentions = []
@tagged_objects = []
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@@ -56,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ApplicationRecord.transaction do
@status = Status.create!(@params.merge(quote: @quote))
attach_tags(@status)
attach_tagged_objects(@status)
attach_mentions(@status)
attach_counts(@status)
end
@@ -181,6 +183,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_tagged_objects(status)
@tagged_objects.each do |tagged_object|
tagged_object.status = status
tagged_object.save
end
end
def attach_mentions(status)
@mentions.each do |mention|
mention.status = status
@@ -210,6 +219,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_mention tag
elsif equals_or_includes?(tag['type'], 'Emoji')
process_emoji tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
process_tagged_collection tag
end
end
end
@@ -266,6 +277,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_tagged_collection(tag)
return if tag['id'].blank?
# TODO: We probably want to resolve unknown objects and push them to an `@unresolved_tagged_objects` on failure
collection = ActivityPub::TagManager.instance.uri_to_resource(tag['id'], Collection)
@tagged_objects << TaggedObject.new(uri: ActivityPub::TagManager.instance.uri_for(collection), object: collection, ap_type: 'FeaturedCollection') if collection.present?
end
def process_attachments
return [] if @object['attachment'].nil?

View File

@@ -13,6 +13,8 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
update_account
elsif supported_object_type? || converted_object_type?
update_status
elsif equals_or_includes_any?(@object['type'], ['FeaturedCollection']) && Mastodon::Feature.collections_federation_enabled?
update_collection
end
end
@@ -41,6 +43,12 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id])
end
def update_collection
return reject_payload! if non_matching_uri_hosts?(@account.uri, object_uri)
ActivityPub::ProcessFeaturedCollectionService.new.call(@account, @object)
end
def object_too_old?
@object['published'].present? && @object['published'].to_datetime < OBJECT_AGE_THRESHOLD.ago
rescue Date::Error

View File

@@ -30,7 +30,7 @@ class ActivityPub::Parser::MediaAttachmentParser
def description
str = @json['summary'].presence || @json['name'].presence
str = str.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if str.present?
str = str.strip[0...MediaAttachment::MAX_DESCRIPTION_HARD_LENGTH_LIMIT] if str.present?
str
end

View File

@@ -262,6 +262,14 @@ class ActivityPub::TagManager
uri_to_resource(uri, Account)
end
def uri_to_local_collection(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'collections'
# TODO: check account, but this requires handling potentially two different schemes
Collection.find_by(id: path_params[:id])
end
def uri_to_local_conversation(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'activitypub/contexts'
@@ -279,6 +287,8 @@ class ActivityPub::TagManager
uris_to_local_accounts([uri]).first
when 'Conversation'
uri_to_local_conversation(uri)
when 'Collection'
uri_to_local_collection(uri)
else
StatusFinder.new(uri).status
end

View File

@@ -25,7 +25,7 @@ module Account::Avatar
validates_attachment_size :avatar, less_than: AVATAR_LIMIT
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false
validates :avatar_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }
validates :avatar_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }, if: -> { local? && will_save_change_to_avatar_description? }
end
def avatar_original_url

View File

@@ -26,7 +26,7 @@ module Account::Header
validates_attachment_size :header, less_than: HEADER_LIMIT
remotable_attachment :header, HEADER_LIMIT, suppress_errors: false
validates :header_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }
validates :header_description, length: { maximum: MediaAttachment::MAX_DESCRIPTION_LENGTH }, if: -> { local? && will_save_change_to_header_description? }
end
def header_original_url

View File

@@ -231,7 +231,7 @@ module Account::Interactions
Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}/") do
digest = "\x00" * 32
followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(url_prefix)}/%", false, true)).or(followers.where(uri: url_prefix)).pluck_each(:uri) do |uri|
followers.matches_uri_prefix(url_prefix).pluck_each(:uri) do |uri|
Xorcist.xor!(digest, Digest::SHA256.digest(uri))
end
digest.unpack1('H*')

View File

@@ -39,6 +39,7 @@ class MediaAttachment < ApplicationRecord
enum :processing, { queued: 0, in_progress: 1, complete: 2, failed: 3 }, prefix: true
MAX_DESCRIPTION_LENGTH = 1_500
MAX_DESCRIPTION_HARD_LENGTH_LIMIT = 10_000
IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 16.megabytes).to_i
VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 99.megabytes).to_i

View File

@@ -47,6 +47,20 @@ class Status < ApplicationRecord
include Status::Visibility
include Status::InteractionPolicyConcern
CACHEABLE_ASSOCIATIONS = [
:application,
:conversation,
:media_attachments,
:preloadable_poll,
:status_stat,
:tags,
account: [:account_stat, user: :role],
active_mentions: :account,
tagged_objects: :object,
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
quote: { status: { account: [:account_stat, user: :role] } },
].freeze
MEDIA_ATTACHMENTS_LIMIT = 4
rate_limit by: :account, family: :statuses
@@ -82,6 +96,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
has_many :tagged_objects, dependent: :destroy
has_many :quotes, foreign_key: 'quoted_status_id', inverse_of: :quoted_status, dependent: :nullify
# The `dependent` option is enabled by the initial `mentions` association declaration
@@ -169,29 +184,11 @@ class Status < ApplicationRecord
# the `dependent: destroy` callbacks remove relevant records
before_destroy :unlink_from_conversations!, prepend: true
cache_associated :application,
:media_attachments,
:conversation,
:status_stat,
:tags,
:preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
reblog: [
:application,
:media_attachments,
:conversation,
:status_stat,
:tags,
:preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
],
thread: :account
cache_associated(
*CACHEABLE_ASSOCIATIONS,
reblog: [*CACHEABLE_ASSOCIATIONS],
thread: :account
)
delegate :domain, :indexable?, to: :account, prefix: true

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: tagged_objects
#
# id :bigint(8) not null, primary key
# ap_type :string not null
# object_type :string
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
# object_id :bigint(8)
# status_id :bigint(8) not null
#
class TaggedObject < ApplicationRecord
belongs_to :status, inverse_of: :tagged_objects
belongs_to :object, polymorphic: true, optional: true
end

View File

@@ -8,7 +8,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :suspended,
:memorial, :indexable, :attribution_domains
:memorial, :indexable, :attribution_domains, :profile_settings
context_extensions :interaction_policies if Mastodon::Feature.collections_enabled?
@@ -16,7 +16,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
:inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary,
:url, :manually_approves_followers,
:discoverable, :indexable, :published, :memorial
:discoverable, :indexable, :published, :memorial,
:show_featured, :show_media
attribute :show_media_replies, key: :show_replies_in_media
attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? }
attribute :featured_collections, if: -> { Mastodon::Feature.collections_enabled? }

View File

@@ -151,7 +151,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
def virtual_tags
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis + object.tagged_objects.map(&:object)
end
def atom_uri
@@ -361,8 +361,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer; end
class CollectionSerializer < ActivityPub::FeaturedCollectionSerializer; end
class OptionSerializer < ActivityPub::Serializer
class RepliesSerializer < ActivityPub::Serializer

View File

@@ -5,7 +5,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer
:local, :sensitive, :discoverable, :item_count,
:created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer
belongs_to :tag, serializer: REST::ShallowTagSerializer
has_many :items, serializer: REST::CollectionItemSerializer

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::ShallowTagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end

View File

@@ -30,6 +30,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :tagged_collections, serializer: REST::CollectionSerializer
# Due to a ActiveModel::Serializer quirk, if you change any of the following, have a look at
# updating `app/serializers/rest/shallow_status_serializer.rb` as well
@@ -170,6 +171,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def tagged_collections
object.tagged_objects.filter_map { |tagged_object| tagged_object.object if tagged_object.ap_type == 'FeaturedCollection' }
end
def quote_approval
{
automatic: object.proper.quote_policy_as_keys(:automatic),
@@ -212,13 +217,5 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
class TagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end
class TagSerializer < REST::ShallowTagSerializer; end
end

View File

@@ -135,6 +135,9 @@ class ActivityPub::ProcessAccountService < BaseService
@account.discoverable = @json['discoverable'] || false
@account.indexable = @json['indexable'] || false
@account.memorial = @json['memorial'] || false
@account.show_featured = @json['showFeatured'] if @json.key?('showFeatured')
@account.show_media = @json['showMedia'] if @json.key?('showMedia')
@account.show_media_replies = @json['showRepliesInMedia'] if @json.key?('showRepliesInMedia')
@account.attribution_domains = as_array(@json['attributionDomains'] || []).take(Account::ATTRIBUTION_DOMAINS_HARD_LIMIT).map { |item| value_or_id(item) }
end
@@ -238,7 +241,7 @@ class ActivityPub::ProcessAccountService < BaseService
url = first_of_value(value['url'])
url = url['href'] if url.is_a?(Hash)
description = value['summary'].presence || value['name'].presence
description = description.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if description.present?
description = description.strip[0...MediaAttachment::MAX_DESCRIPTION_HARD_LENGTH_LIMIT] if description.present?
else
url = value
end

View File

@@ -14,21 +14,22 @@ class ActivityPub::ProcessFeaturedCollectionService
return if non_matching_uri_hosts?(@account.uri, @json['id'])
with_redis_lock("collection:#{@json['id']}") do
return if @account.collections.exists?(uri: @json['id'])
Collection.transaction do
@collection = @account.collections.find_or_initialize_by(uri: @json['id'])
@collection = @account.collections.create!(
local: false,
uri: @json['id'],
name: (@json['name'] || '')[0, Collection::NAME_LENGTH_HARD_LIMIT],
description_html: truncated_summary,
language:,
sensitive: @json['sensitive'],
discoverable: @json['discoverable'],
original_number_of_items: @json['totalItems'] || 0,
tag_name: @json.dig('topic', 'name')
)
@collection.update!(
local: false,
name: (@json['name'] || '')[0, Collection::NAME_LENGTH_HARD_LIMIT],
description_html: truncated_summary,
language:,
sensitive: @json['sensitive'],
discoverable: @json['discoverable'],
original_number_of_items: @json['totalItems'] || 0,
tag_name: @json.dig('topic', 'name')
)
process_items! if @json['totalItems'].positive?
process_items!
end
@collection
end
@@ -46,8 +47,13 @@ class ActivityPub::ProcessFeaturedCollectionService
end
def process_items!
@json['orderedItems'].take(ITEMS_LIMIT).each do |item_json|
ActivityPub::ProcessFeaturedItemWorker.perform_async(@collection.id, item_json, @request_id)
uris = []
items = @json['orderedItems'] || []
items.take(ITEMS_LIMIT).each_with_index do |item_json, index|
uris << value_or_id(item_json)
ActivityPub::ProcessFeaturedItemWorker.perform_async(@collection.id, item_json, index, @request_id)
end
uris.compact!
@collection.collection_items.where.not(uri: uris).delete_all
end
end

View File

@@ -5,29 +5,24 @@ class ActivityPub::ProcessFeaturedItemService
include Lockable
include Redisable
def call(collection, uri_or_object, request_id: nil)
def call(collection, uri_or_object, position: nil, request_id: nil)
@collection = collection
@request_id = request_id
item_json = uri_or_object.is_a?(String) ? fetch_resource(uri_or_object, true) : uri_or_object
return if non_matching_uri_hosts?(collection.uri, item_json['id'])
@item_json = uri_or_object.is_a?(String) ? fetch_resource(uri_or_object, true) : uri_or_object
return if non_matching_uri_hosts?(@collection.uri, @item_json['id'])
with_redis_lock("collection_item:#{item_json['id']}") do
return if collection.collection_items.exists?(uri: item_json['id'])
with_redis_lock("collection_item:#{@item_json['id']}") do
@collection_item = existing_item || pre_approved_item || new_item
local_account = ActivityPub::TagManager.instance.uris_to_local_accounts([item_json['featuredObject']]).first
@collection_item.update!(
uri: @item_json['id'],
object_uri: value_or_id(@item_json['featuredObject']),
position:
)
if local_account.present?
# This is a local account that has authorized this item already
@collection_item = collection.collection_items.accepted_partial(local_account).first
@collection_item&.update!(uri: item_json['id'])
else
@collection_item = collection.collection_items.create!(
uri: item_json['id'],
object_uri: item_json['featuredObject']
)
@approval_uri = item_json['featureAuthorization']
@approval_uri = @item_json['featureAuthorization']
verify_authorization!
end
verify_authorization! unless @collection_item&.account&.local?
@collection_item
end
@@ -35,6 +30,20 @@ class ActivityPub::ProcessFeaturedItemService
private
def existing_item
@collection.collection_items.find_by(uri: @item_json['id'])
end
def pre_approved_item
# This is a local account that has authorized this item already
local_account = ActivityPub::TagManager.instance.uris_to_local_accounts([@item_json['featuredObject']]).first
@collection.collection_items.accepted_partial(local_account).first if local_account.present?
end
def new_item
@collection.collection_items.new
end
def verify_authorization!
ActivityPub::VerifyFeaturedItemService.new.call(@collection_item, @approval_uri, request_id: @request_id)
rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS

View File

@@ -182,9 +182,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
def update_metadata!
@raw_tags = []
@raw_tags = []
@raw_mentions = []
@raw_emojis = []
@raw_tagged_objects = []
@raw_emojis = []
as_array(@json['tag']).each do |tag|
if equals_or_includes?(tag['type'], 'Hashtag')
@@ -193,10 +194,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@raw_mentions << tag['href'] if tag['href'].present?
elsif equals_or_includes?(tag['type'], 'Emoji')
@raw_emojis << tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
@raw_tagged_objects << tag if tag['id']
end
end
update_tags!
update_tagged_objects!
update_mentions!
update_emojis!
update_quote!
@@ -229,6 +233,24 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
end
def update_tagged_objects!
current_tagged_objects = @raw_tagged_objects.filter_map do |tagged_object|
url = tagged_object['id']
# TODO: We probably want to resolve unknown objects at authoring time
ActivityPub::TagManager.instance.uri_to_resource(url, Collection)
end
# Any previously-unresolved URI would be resolved here
@status.tagged_objects.upsert_all(
current_tagged_objects.uniq.map { |object| { object_type: object.class.name, object_id: object.id, uri: ActivityPub::TagManager.instance.uri_for(object), ap_type: 'FeaturedCollection' } },
unique_by: %w(status_id uri)
)
# Remove unused links
@status.tagged_objects.where.not(uri: current_tagged_objects.map { |object| ActivityPub::TagManager.instance.uri_for(object) }).delete_all
end
def update_mentions!
unresolved_mentions = []

View File

@@ -93,9 +93,10 @@ class PostStatusService < BaseService
def process_status!
@status = @account.statuses.new(status_attributes)
process_mentions_service.call(@status, save_records: false)
process_mentions_service.call(@status)
safeguard_mentions!(@status)
safeguard_private_mention_quote!(@status)
attach_tagged_objects!(@status)
attach_quote!(@status)
antispam = Antispam.new(@status)
@@ -127,6 +128,10 @@ class PostStatusService < BaseService
status.quote.accept! if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote?
end
def attach_tagged_objects!(status)
ProcessLinksService.new.call(status)
end
def safeguard_mentions!(status)
return if @options[:allowed_mentions].nil?

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ProcessLinksService < BaseService
include Payloadable
# Scan status for links to ActivityPub objects and attach them to statuses
# @param [Status] status
def call(status)
return unless status.local?
@status = status
@previous_objects = @status.tagged_objects.includes(:object).to_a
@current_objects = []
Status.transaction do
scan_text!
assign_tagged_objects!
end
end
private
def scan_text!
urls = @status.text.scan(FetchLinkCardService::URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
urls.each do |url|
# We only support `FeaturedCollection` at this time
# TODO: We probably want to resolve unknown objects at authoring time
object = ActivityPub::TagManager.instance.uri_to_resource(url.to_s, Collection)
next if object.nil?
tagged_object = @previous_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @current_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @status.tagged_objects.new(object: object, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(object))
@current_objects << tagged_object
end
end
def assign_tagged_objects!
return unless @status.persisted?
@current_objects.each do |object|
object.save if object.new_record?
end
# If previous objects are no longer contained in the text, remove them to lighten the database
removed_objects = @previous_objects - @current_objects
TaggedObject.where(id: removed_objects.map(&:id)).delete_all unless removed_objects.empty?
end
end

View File

@@ -6,10 +6,8 @@ class ProcessMentionsService < BaseService
# Scan status for mentions and fetch remote mentioned users,
# and create local mention pointers
# @param [Status] status
# @param [Boolean] save_records Whether to save records in database
def call(status, save_records: true)
def call(status)
@status = status
@save_records = save_records
return unless @status.local?
@@ -64,7 +62,7 @@ class ProcessMentionsService < BaseService
"@#{mentioned_account.acct}"
end
@status.save! if @save_records
@status.save! if @status.persisted?
end
def assign_mentions!
@@ -79,8 +77,10 @@ class ProcessMentionsService < BaseService
dropped_mentions.each(&:destroy)
end
return unless @status.persisted?
@current_mentions.each do |mention|
mention.save if (mention.new_record? || mention.silent_changed?) && @save_records
mention.save if mention.new_record? || mention.silent_changed?
end
# If previous mentions are no longer contained in the text, convert them

View File

@@ -136,6 +136,7 @@ class UpdateStatusService < BaseService
def update_metadata!
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status)
ProcessLinksService.new.call(@status)
end
def broadcast_updates!

View File

@@ -6,10 +6,10 @@ class ActivityPub::ProcessFeaturedItemWorker
sidekiq_options queue: 'pull', retry: 3
def perform(collection_id, id_or_json, request_id = nil)
def perform(collection_id, id_or_json, position = nil, request_id = nil)
collection = Collection.find(collection_id)
ActivityPub::ProcessFeaturedItemService.new.call(collection, id_or_json, request_id:)
ActivityPub::ProcessFeaturedItemService.new.call(collection, id_or_json, position:, request_id:)
rescue ActiveRecord::RecordNotFound
true
end

View File

@@ -9,6 +9,7 @@ class PollExpirationNotifyWorker
@poll = Poll.find(poll_id)
return if missing_expiration?
requeue! && return if not_due_yet?
notify_remote_voters_and_owner! if @poll.local?

View File

@@ -361,7 +361,7 @@ es-MX:
copy_failed_msg: No se pudo realizar una copia local de ese emoji
create_new_category: Crear una nueva categoría
created_msg: "¡Emoji creado con éxito!"
delete: Borrar
delete: Eliminar
destroyed_msg: "¡Emojo destruido con éxito!"
disable: Deshabilitar
disabled: Desactivado
@@ -473,7 +473,7 @@ es-MX:
one: "%{count} intentos durante la última semana"
other: "%{count} intentos de registro en la última semana"
created_msg: Dominio de correo bloqueado con éxito
delete: Borrar
delete: Eliminar
dns:
types:
mx: Registro MX
@@ -610,7 +610,7 @@ es-MX:
private_comment: Comentario privado
public_comment: Comentario público
purge: Purgar
purge_description_html: Si crees que este dominio está desconectado, puedes borrar todos los registros de cuentas y los datos asociados de este dominio de tu almacenamiento. Esto puede llevar un tiempo.
purge_description_html: Si crees que este dominio ya no está activo, puedes eliminar de tu almacenamiento todos los registros de la cuenta y los datos asociados a este dominio. Esto puede tardar un rato.
title: Instancias conocidas
total_blocked_by_us: Bloqueado por nosotros
total_followed_by_them: Seguidos por ellos
@@ -646,7 +646,7 @@ es-MX:
title: Relaciones de %{acct}
relays:
add_new: Añadir nuevo relé
delete: Borrar
delete: Eliminar
description_html: Un <strong>relés de federación</strong> es un servidor intermedio que intercambia grandes volúmenes de publicaciones públicas entre servidores que se suscriben y publican en él. <strong>Puede ayudar a servidores pequeños y medianos a descubrir contenido del fediverso</strong>, que de otra manera requeriría que los usuarios locales siguiesen manualmente a personas de servidores remotos.
disable: Deshabilitar
disabled: Deshabilitado
@@ -776,7 +776,7 @@ es-MX:
privileges:
administrator: Administrador
administrator_description: Los usuarios con este permiso saltarán todos los permisos
delete_user_data: Borrar Datos de Usuario
delete_user_data: Eliminar datos de usuario
delete_user_data_description: Permite a los usuarios eliminar los datos de otros usuarios sin demora
invite_bypass_approval: Invitar a usuarios sin revisión
invite_bypass_approval_description: Permite que las personas invitadas al servidor por estos usuarios no tengan que pasar por el proceso de aprobación de la moderación
@@ -1148,7 +1148,7 @@ es-MX:
updated_msg: Regla de nombre de usuario actualizada correctamente
warning_presets:
add_new: Añadir nuevo
delete: Borrar
delete: Eliminar
edit_preset: Editar aviso predeterminado
empty: Aún no has definido ningún preajuste de advertencia.
title: Preajustes de advertencia
@@ -1260,7 +1260,7 @@ es-MX:
registration_complete: "¡Tu registro en %{domain} ha sido completado!"
welcome_title: "¡Bienvenido, %{name}!"
wrong_email_hint: Si esa dirección de correo electrónico no es correcta, puedes cambiarla en la configuración de la cuenta.
delete_account: Borrar cuenta
delete_account: Eliminar cuenta
delete_account_html: Si deseas eliminar tu cuenta, puedes <a href="%{path}">proceder aquí</a>. Se te pedirá una confirmación.
description:
prefix_invited_by_user: "¡@%{name} te invita a unirte a este servidor de Mastodon!"
@@ -1457,10 +1457,10 @@ es-MX:
mutes: Tienes en silencio
storage: Almacenamiento
featured_tags:
add_new: Añadir nuevo
add_new: Añadir nueva
errors:
limit: Ya has alcanzado la cantidad máxima de etiquetas
hint_html: "<strong>¿Qué son las etiquetas destacadas?</strong> Se muestran de forma prominente en tu perfil público y permiten a los usuarios navegar por tus publicaciones públicas específicamente bajo esas etiquetas. Son una gran herramienta para hacer un seguimiento de trabajos creativos o proyectos a largo plazo."
hint_html: "<strong>Destaca tus etiquetas más importantes en tu perfil.</strong> Una herramienta fantástica para llevar un registro de tus trabajos creativos y proyectos a largo plazo; las etiquetas destacadas aparecen en un lugar visible de tu perfil y te permiten acceder rápidamente a tus propias publicaciones."
filters:
contexts:
account: Perfiles
@@ -1479,7 +1479,7 @@ es-MX:
invalid_context: Se suminstró un contexto inválido o vacío
index:
contexts: Filtros en %{contexts}
delete: Borrar
delete: Eliminar
empty: No tienes filtros.
expires_in: Caduca en %{distance}
expires_on: Expira el %{date}
@@ -1913,7 +1913,7 @@ es-MX:
appearance: Apariencia
authorized_apps: Aplicaciones autorizadas
back: Volver al inicio
delete: Borrar cuenta
delete: Eliminar cuenta
development: Desarrollo
edit_profile: Editar perfil
export: Exportar
@@ -1991,7 +1991,7 @@ es-MX:
unlisted: Pública, pero silenciosa
unlisted_long: Ocultado de los resultados de búsqueda, tendencias y cronologías públicas de Mastodon
statuses_cleanup:
enabled: Borrar automáticamente publicaciones antiguas
enabled: Eliminar automáticamente las publicaciones antiguas
enabled_hint: Elimina automáticamente tus publicaciones una vez que alcancen un umbral de tiempo especificado, a menos que coincidan con alguna de las excepciones detalladas debajo
exceptions: Excepciones
explanation: La eliminación automática se realiza con baja prioridad. Puede haber un retraso entre el momento en que se alcanza el límite de antigüedad y el momento en que se elimina.

View File

@@ -778,6 +778,7 @@ fi:
administrator_description: Käyttäjät, joilla on tämä käyttöoikeus, ohittavat jokaisen käyttöoikeuden
delete_user_data: Poistaa käyttäjän tiedot
delete_user_data_description: Sallii käyttäjien poistaa muiden käyttäjien tiedot viipymättä
invite_bypass_approval: Kutsu käyttäjiä arvioimatta
invite_users: Kutsua käyttäjiä
invite_users_description: Sallii käyttäjien kutsua uusia käyttäjiä palvelimelle
manage_announcements: Hallita tiedotteita

View File

@@ -781,6 +781,8 @@ fr-CA:
administrator_description: Les utilisateur⋅rice⋅s ayant cette autorisation pourront contourner toutes les autorisations
delete_user_data: Supprimer les données de l'utilisateur⋅rice
delete_user_data_description: Permet aux utilisateur⋅rice⋅s de supprimer sans délai les données des autres utilisateur⋅rice⋅s
invite_bypass_approval: Inviter des utilisateurs sans vérifications
invite_bypass_approval_description: Permet aux personnes invitées sur le serveur par ces utilisateur·rice·s de s'inscrire sans être soumises à l'approbation de la modération
invite_users: Inviter des utilisateur⋅rice⋅s
invite_users_description: Permet aux utilisateur⋅rice⋅s d'inviter de nouvelles personnes sur le serveur
manage_announcements: Gérer les annonces

View File

@@ -781,6 +781,8 @@ fr:
administrator_description: Les utilisateur⋅rice⋅s ayant cette autorisation pourront contourner toutes les autorisations
delete_user_data: Supprimer les données de l'utilisateur⋅rice
delete_user_data_description: Permet aux utilisateur⋅rice⋅s de supprimer sans délai les données des autres utilisateur⋅rice⋅s
invite_bypass_approval: Inviter des utilisateurs sans vérifications
invite_bypass_approval_description: Permet aux personnes invitées sur le serveur par ces utilisateur·rice·s de s'inscrire sans être soumises à l'approbation de la modération
invite_users: Inviter des utilisateur⋅rice⋅s
invite_users_description: Permet aux utilisateur⋅rice⋅s d'inviter de nouvelles personnes sur le serveur
manage_announcements: Gérer les annonces

View File

@@ -762,6 +762,8 @@ nan-TW:
administrator_description: 有tsit ê權限ê用者ē忽略所有ê權限。
delete_user_data: Thâi掉用者ê資料
delete_user_data_description: 允准用者liâm-mi thâi掉其他用者ê資料
invite_bypass_approval: 邀請iáu bē審查ê用者
invite_bypass_approval_description: 允准予tsiah ê用者邀請kàu tsit臺服侍器ê lâng làng過管理審查許可
invite_users: 邀請用者
invite_users_description: 允准用者邀請新lâng來tsit ê服侍器
manage_announcements: 管理公告
@@ -1238,7 +1240,7 @@ nan-TW:
welcome_title: 歡迎 %{name}
wrong_email_hint: Nā是電子phue ê地址無正確lí ē當tī口座設定kā改。
delete_account: Thâi掉口座
delete_account_html: Nā lí behthâi掉lí ê口座lí ē當<a href="%{path}">ji̍h tsia繼續</a>。Lí著確認動作。
delete_account_html: Nā lí beh thâi掉lí ê口座lí ē當<a href="%{path}">ji̍h tsia繼續</a>。Lí著確認動作。
description:
prefix_invited_by_user: "@%{name} 邀請lí加入tsit ê Mastodon 服侍器!"
prefix_sign_up: Tsit-má註冊Mastodon ê口座!
@@ -1258,6 +1260,7 @@ nan-TW:
progress:
confirm: 確認電子phue
details: Lí ê詳細
list: 註冊流程
review: Lán ê審查
rules: 接受規則
providers:
@@ -1273,6 +1276,7 @@ nan-TW:
invited_by: Lí通用有tuì hia收著ê邀請加入 %{domain}
preamble: Tsiah-ê hōo %{domain} ê管理員設定kap實施。
preamble_invited: 佇lí繼續進前請思考 %{domain} ê管理員設立ê基本規則。
read_more: 讀詳細
title: Tsi̍t-kuá基本規定。
title_invited: Lí受邀請ah。
security: 安全
@@ -1953,6 +1957,36 @@ nan-TW:
keep_direct: 保留私人ê短phue
keep_direct_hint: Bē thâi掉任何lí ê私人ê提起
keep_media: 保留有媒體附件ê PO文
keep_media_hint: Bē thâi掉lí ê任何有媒體附件ê PO文
keep_pinned: 保留所釘ê PO文
keep_pinned_hint: Bē thâi掉任何lí所釘ê PO文
keep_polls: 保留投票
keep_polls_hint: Bē thâi掉任何lí ê投票
keep_self_bookmark: 保留lí加冊籤ê PO文
keep_self_bookmark_hint: Bē thâi掉lí家kī有加冊籤ê PO文
keep_self_fav: 保留lí收藏ê PO文
keep_self_fav_hint: Bē thâi掉lí家kī有收藏ê PO文
min_age:
'1209600': 2 禮拜
'15778476': 6 個月
'2629746': 1 個月
'31556952': 1
'5259492': 2 個月
'604800': 1 禮拜
'63113904': 2
'7889238': 3 個月
min_age_label: 保持PO文ê期間
min_favs: 保留超過下kha ê收藏數ê PO文
min_favs_hint: 若是lí ê PO文有kàu tsit ê收藏數就bē受thâi掉。留空白表示毋管收藏數PO文lóng thâi掉
min_reblogs: 保留超過下kha ê轉送數ê PO文
min_reblogs_hint: 若是lí ê PO文有kàu tsit ê轉送數就bē受thâi掉。留空白表示毋管轉送數PO文lóng thâi掉
stream_entries:
sensitive_content: 敏感ê內容
strikes:
errors:
too_late: Lí bē赴申訴tsit ê處份ah
tags:
does_not_match_previous_name: kap進前ê名無kâng
terms_of_service:
title: 服務規定
themes:

View File

@@ -778,6 +778,8 @@ nl:
administrator_description: Deze gebruikers hebben volledige rechten en kun dus overal bij
delete_user_data: Gebruikersgegevens verwijderen
delete_user_data_description: Staat gebruikers toe om de gegevens van andere gebruikers zonder vertraging te verwijderen
invite_bypass_approval: Gebruikers uitnodigen zonder beoordeling
invite_bypass_approval_description: Staat het toe dat mensen die door deze gebruikers voor deze server zijn uitgenodigd, niet door moderatoren beoordeeld hoeven te worden
invite_users: Gebruikers uitnodigen
invite_users_description: Staat gebruikers toe om nieuwe mensen voor de server uit te nodigen
manage_announcements: Aankondigingen beheren
@@ -1279,7 +1281,7 @@ nl:
progress:
confirm: E-mailadres bevestigen
details: Jouw gegevens
list: Voortgang aanmelding
list: Voortgang registratie
review: Onze beoordeling
rules: Regels accepteren
providers:
@@ -1295,6 +1297,7 @@ nl:
invited_by: 'Je kunt je registreren op %{domain} dankzij de uitnodiging die je hebt ontvangen van:'
preamble: Deze zijn vastgesteld en worden gehandhaafd door de moderatoren van %{domain}.
preamble_invited: Voordat je verder gaat, moet je eerst kennisnemen van de basisregels die door de moderatoren van %{domain} zijn vastgesteld.
read_more: Meer lezen
title: Enkele basisregels.
title_invited: Je bent uitgenodigd.
security: Beveiliging

View File

@@ -77,7 +77,7 @@ es-MX:
domain: Este puede ser el nombre de dominio que se muestra en la dirección de correo o el registro MX que utiliza. Se comprobarán al registrarse.
with_dns_records: Se hará un intento de resolver los registros DNS del dominio dado y los resultados serán también puestos en lista negra
featured_tag:
name: 'Aquí están algunas de las etiquetas que más has usado recientemente:'
name: 'Estas son algunas de las etiquetas que más has utilizado recientemente:'
filters:
action: Elige qué acción realizar cuando una publicación coincida con el filtro
actions:

View File

@@ -778,6 +778,8 @@ tr:
administrator_description: Bu izne sahip kullanıcılar tüm diğer izinleri atlıyorlar
delete_user_data: Kullanıcı Verilerini Silme
delete_user_data_description: Kullanıcıların, diğer kullanıcıların verisini gecikme olmaksızın silmesine izin verir
invite_bypass_approval: Kullanıcıları incelemeden davet et
invite_bypass_approval_description: Bu kullanıcılar tarafından sunucuya davet edilen kişilerin moderasyon onayını atlamasına izin verir
invite_users: Kullanıcıları Davet Etme
invite_users_description: Kullanıcıların yeni kişileri sunucuya davet etmesine izin verir
manage_announcements: Duyuruları Yönetme

View File

@@ -762,6 +762,8 @@ zh-CN:
administrator_description: 拥有此权限的用户将绕过所有权限限制。
delete_user_data: 删除用户数据
delete_user_data_description: 允许用户立即删除其他用户的数据
invite_bypass_approval: 邀请未经审核的用户
invite_bypass_approval_description: 允许被这些用户邀请到此服务器的人们绕过管理审核批准
invite_users: 邀请用户
invite_users_description: 允许用户邀请新人加入站点
manage_announcements: 管理公告

View File

@@ -1,6 +1,5 @@
# frozen_string_literal: true
require 'sidekiq_unique_jobs/web' if ENV['ENABLE_SIDEKIQ_UNIQUE_JOBS_UI'] == true
require 'sidekiq-scheduler/web'
class RedirectWithVary < ActionDispatch::Routing::PathRedirect

View File

@@ -19,7 +19,7 @@ export function GlitchThemes(): Plugin {
return {
name: 'glitch-themes',
async config(userConfig) {
const existingInputs = userConfig.build?.rollupOptions?.input;
const existingInputs = userConfig.build?.rolldownOptions?.input;
if (typeof existingInputs === 'string') {
entrypoints[path.basename(existingInputs)] = existingInputs;
@@ -79,7 +79,7 @@ export function GlitchThemes(): Plugin {
return {
build: {
rollupOptions: {
rolldownOptions: {
input: entrypoints,
},
},

View File

@@ -24,7 +24,7 @@ export function MastodonThemes(): Plugin {
let entrypoints: Record<string, string> = {};
const existingInputs = userConfig.build?.rollupOptions?.input;
const existingInputs = userConfig.build?.rolldownOptions?.input;
if (typeof existingInputs === 'string') {
entrypoints[path.basename(existingInputs)] = existingInputs;
@@ -46,7 +46,7 @@ export function MastodonThemes(): Plugin {
return {
build: {
rollupOptions: {
rolldownOptions: {
input: entrypoints,
},
},

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateTaggedObjects < ActiveRecord::Migration[8.1]
def change
create_table :tagged_objects do |t|
t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: false
t.references :object, polymorphic: true, null: true
t.string :ap_type, null: false
t.string :uri
t.timestamps
end
add_index :tagged_objects, [:status_id, :object_type, :object_id], unique: true, where: 'object_type IS NOT NULL AND object_id IS NOT NULL'
add_index :tagged_objects, [:status_id, :uri], unique: true, where: 'uri IS NOT NULL'
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -1245,6 +1245,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
t.index ["tag_id", "language"], name: "index_tag_trends_on_tag_id_and_language", unique: true
end
create_table "tagged_objects", force: :cascade do |t|
t.string "ap_type", null: false
t.datetime "created_at", null: false
t.bigint "object_id"
t.string "object_type"
t.bigint "status_id", null: false
t.datetime "updated_at", null: false
t.string "uri"
t.index ["object_type", "object_id"], name: "index_tagged_objects_on_object"
t.index ["status_id", "object_type", "object_id"], name: "idx_on_status_id_object_type_object_id_d6ebe374bd", unique: true, where: "((object_type IS NOT NULL) AND (object_id IS NOT NULL))"
t.index ["status_id", "uri"], name: "index_tagged_objects_on_status_id_and_uri", unique: true, where: "(uri IS NOT NULL)"
end
create_table "tags", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.string "display_name"
@@ -1550,6 +1563,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
add_foreign_key "tag_follows", "accounts", on_delete: :cascade
add_foreign_key "tag_follows", "tags", on_delete: :cascade
add_foreign_key "tag_trends", "tags", on_delete: :cascade
add_foreign_key "tagged_objects", "statuses", on_delete: :cascade
add_foreign_key "tombstones", "accounts", on_delete: :cascade
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade

View File

@@ -17,7 +17,6 @@ module Mastodon::CLI
LONG_DESC
def rotate(username = nil)
if options[:all]
processed = 0
delay = 0
scope = Account.local.without_suspended
progress = create_progress_bar(scope.count)
@@ -26,14 +25,13 @@ module Mastodon::CLI
accounts.each do |account|
rotate_keys_for_account(account, delay)
progress.increment
processed += 1
end
delay += 5.minutes
end
progress.finish
say("OK, rotated keys for #{processed} accounts", :green)
say("OK, rotated keys for #{progress.progress} accounts", :green)
elsif username.present?
rotate_keys_for_account(Account.find_local(username))
say('OK', :green)
@@ -442,7 +440,6 @@ module Mastodon::CLI
total += account.following.reorder(nil).count if options[:follows]
total += account.followers.reorder(nil).count if options[:followers]
progress = create_progress_bar(total)
processed = 0
if options[:follows]
account.following.reorder(nil).find_each do |target_account|
@@ -451,7 +448,6 @@ module Mastodon::CLI
progress.log pastel.red("Error processing #{target_account.id}: #{e}")
ensure
progress.increment
processed += 1
end
BootstrapTimelineWorker.perform_async(account.id)
@@ -464,12 +460,11 @@ module Mastodon::CLI
progress.log pastel.red("Error processing #{target_account.id}: #{e}")
ensure
progress.increment
processed += 1
end
end
progress.finish
say("Processed #{processed} relationships", :green, true)
say("Processed #{progress.progress} relationships", :green, true)
end
option :number, type: :numeric, aliases: [:n]

View File

@@ -1,7 +1,7 @@
{
"name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.12.0",
"packageManager": "yarn@4.13.0",
"engines": {
"node": ">=20"
},
@@ -51,8 +51,9 @@
"@optimize-lodash/rollup-plugin": "^6.0.0",
"@react-spring/web": "^9.7.5",
"@reduxjs/toolkit": "^2.0.1",
"@rolldown/plugin-babel": "^0.2.2",
"@use-gesture/react": "^10.3.1",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-legacy": "^8.0.0",
"@vitejs/plugin-react": "^5.0.0",
"arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.5.0",
@@ -110,8 +111,8 @@
"redux-immutable": "^4.0.0",
"regenerator-runtime": "^0.14.0",
"requestidlecallback": "^0.3.0",
"rollup-plugin-gzip": "^4.1.1",
"rollup-plugin-visualizer": "^6.0.3",
"rollup-plugin-gzip": "^4.2.0",
"rollup-plugin-visualizer": "^7.0.1",
"sass": "^1.62.1",
"scroll-behavior": "^0.11.0",
"stacktrace-js": "^2.0.2",
@@ -121,11 +122,10 @@
"tiny-queue": "^0.2.1",
"twitter-text": "3.1.0",
"use-debounce": "^10.0.0",
"vite": "^7.1.1",
"vite": "^8.0.0",
"vite-plugin-manifest-sri": "^0.2.0",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-svgr": "^4.3.0",
"vite-tsconfig-paths": "^6.0.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svgr": "^4.5.0",
"wicg-inert": "^3.1.2",
"workbox-expiration": "^7.3.0",
"workbox-routing": "^7.3.0",
@@ -135,10 +135,10 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@formatjs/cli": "^6.1.1",
"@storybook/addon-a11y": "^10.0.6",
"@storybook/addon-docs": "^10.0.6",
"@storybook/addon-vitest": "^10.0.6",
"@storybook/react-vite": "^10.0.6",
"@storybook/addon-a11y": "^10.3.0",
"@storybook/addon-docs": "^10.3.0",
"@storybook/addon-vitest": "^10.3.0",
"@storybook/react-vite": "^10.3.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/debug": "^4",
@@ -163,10 +163,10 @@
"@types/react-toggle": "^4.0.3",
"@types/redux-immutable": "^4.0.3",
"@types/requestidlecallback": "^0.3.5",
"@vitest/browser": "^4.0.5",
"@vitest/browser-playwright": "^4.0.5",
"@vitest/coverage-v8": "^4.0.5",
"@vitest/ui": "^4.0.5",
"@vitest/browser": "^4.1.0",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"chromatic": "^13.3.3",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.2.5",
@@ -187,13 +187,13 @@
"oxfmt": "^0.33.0",
"playwright": "^1.57.0",
"react-test-renderer": "^18.2.0",
"storybook": "^10.0.5",
"storybook": "^10.3.0",
"stylelint": "^17.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.0",
"typescript-eslint": "^8.55.0",
"typescript-plugin-css-modules": "^5.2.0",
"vitest": "^4.0.5"
"vitest": "^4.1.0"
},
"resolutions": {
"@types/react": "^18.2.7",

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
Fabricator(:tagged_object) do
status
object nil
ap_type 'FeaturedCollection'
uri { Faker::Internet.device_token }
end

View File

@@ -731,6 +731,30 @@ RSpec.describe ActivityPub::Activity::Create do
end
end
context 'with tagged Featured Collections' do
let(:featured_collection) { Fabricate(:collection) }
let(:object_json) do
build_object(
tag: [
{
type: 'FeaturedCollection',
id: ActivityPub::TagManager.instance.uri_for(featured_collection),
},
]
)
end
it 'creates the status with appropriate tagged objects' do
expect { subject.perform }
.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status.tagged_objects.map(&:object)).to contain_exactly(featured_collection)
end
end
context 'with hashtags' do
let(:object_json) do
build_object(

View File

@@ -256,5 +256,45 @@ RSpec.describe ActivityPub::Activity::Update do
end
end
end
context 'with a `FeaturedCollection` object', feature: :collections_federation do
let(:collection) { Fabricate(:remote_collection, account: sender, name: 'old name', discoverable: false) }
let(:featured_collection_json) do
{
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => collection.uri,
'type' => 'FeaturedCollection',
'attributedTo' => sender.uri,
'name' => 'Cool people',
'summary' => 'People you should follow.',
'totalItems' => 0,
'sensitive' => false,
'discoverable' => true,
'published' => '2026-03-09T15:19:25Z',
'updated' => Time.zone.now.iso8601,
}
end
let(:json) do
{
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Update',
'actor' => sender.uri,
'object' => featured_collection_json,
}
end
let(:stubbed_service) do
instance_double(ActivityPub::ProcessFeaturedCollectionService, call: true)
end
before do
allow(ActivityPub::ProcessFeaturedCollectionService).to receive(:new).and_return(stubbed_service)
end
it 'updates the collection' do
subject.perform
expect(stubbed_service).to have_received(:call).with(sender, featured_collection_json)
end
end
end
end

View File

@@ -671,5 +671,15 @@ RSpec.describe ActivityPub::TagManager do
status = Fabricate(:status, uri: 'https://example.com/123')
expect(subject.uri_to_resource('https://example.com/123#456', Status)).to eq status
end
it 'returns the local featured collection' do
collection = Fabricate(:collection)
expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection
end
it 'returns the remote featured collection' do
collection = Fabricate(:remote_collection)
expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection
end
end
end

View File

@@ -43,6 +43,26 @@ RSpec.describe ActivityPub::NoteSerializer do
.and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility
end
context 'with tagged featured collections' do
let(:collection) { Fabricate(:collection) }
before do
parent.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection))
end
it 'has the expected shape' do
expect(subject).to include({
'type' => 'Note',
'tag' => include(
a_hash_including({
'type' => 'FeaturedCollection',
'id' => ActivityPub::TagManager.instance.uri_for(collection),
})
),
})
end
end
context 'with a quote' do
let(:quoted_status) { Fabricate(:status) }
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) }

View File

@@ -92,5 +92,22 @@ RSpec.describe REST::StatusSerializer do
)
end
end
context 'with a tagged collection' do
let(:collection) { Fabricate(:collection) }
before do
status.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection))
end
it 'contains the tagged collection' do
expect(subject)
.to include(
'tagged_collections' => [a_hash_including(
'id' => collection.id.to_s
)]
)
end
end
end
end

View File

@@ -114,6 +114,30 @@ RSpec.describe ActivityPub::ProcessAccountService do
end
end
context 'with profile settings' do
let(:payload) do
{
id: 'https://foo.test',
type: 'Actor',
inbox: 'https://foo.test/inbox',
showMedia: true,
showRepliesInMedia: false,
showFeatured: false,
}.with_indifferent_access
end
it 'sets the profile settings as expected' do
account = subject.call('alice', 'example.com', payload)
expect(account)
.to have_attributes(
show_media: true,
show_media_replies: false,
show_featured: false
)
end
end
context 'with inlined feature collection' do
let(:payload) do
{

View File

@@ -73,4 +73,36 @@ RSpec.describe ActivityPub::ProcessFeaturedCollectionService do
expect(new_collection.description_html).to eq '<p>A list of remote actors you should follow.</p>'
end
end
context 'when the collection already exists' do
let(:collection) { Fabricate(:remote_collection, account:, uri: base_json['id'], name: 'placeholder') }
before do
Fabricate(:collection_item, collection:, uri: 'https://example.com/featured_items/1')
Fabricate(:collection_item, collection:, uri: 'https://example.com/featured_items/3')
end
it 'updates the existing collection, removes the item that no longer exists and queues a jobs to fetch the other items' do
expect { subject.call(account, featured_collection_json) }
.to change(collection.collection_items, :count).by(-1)
expect(collection.reload.name).to eq 'Good people from other servers'
expect(ActivityPub::ProcessFeaturedItemWorker).to have_enqueued_sidekiq_job.exactly(2).times
end
context 'when the updated collection no longer contains any items' do
let(:featured_collection_json) do
base_json.merge({
'summary' => summary,
'totalItems' => 0,
'orderedItems' => nil,
})
end
it 'removes all items' do
expect { subject.call(account, featured_collection_json) }
.to change(collection.collection_items, :count).by(-2)
end
end
end
end

View File

@@ -8,6 +8,7 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
subject { described_class.new }
let(:collection) { Fabricate(:remote_collection, uri: 'https://other.example.com/collection/1') }
let(:position) { 3 }
let(:featured_object_uri) { 'https://example.com/actor/1' }
let(:feature_authorization_uri) { 'https://example.com/auth/1' }
let(:featured_item_json) do
@@ -34,7 +35,7 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
it 'does not create a collection item and returns `nil`' do
expect do
expect(subject.call(collection, object)).to be_nil
expect(subject.call(collection, object, position:)).to be_nil
end.to_not change(CollectionItem, :count)
end
end
@@ -45,14 +46,29 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
it_behaves_like 'non-matching URIs'
it 'creates and verifies the item' do
expect { subject.call(collection, object) }.to change(collection.collection_items, :count).by(1)
context 'when item does not yet exist' do
it 'creates and verifies the item' do
expect { subject.call(collection, object, position:) }.to change(collection.collection_items, :count).by(1)
expect(stubbed_service).to have_received(:call)
expect(stubbed_service).to have_received(:call)
new_item = collection.collection_items.last
expect(new_item.object_uri).to eq 'https://example.com/actor/1'
expect(new_item.approval_uri).to be_nil
new_item = collection.collection_items.last
expect(new_item.object_uri).to eq 'https://example.com/actor/1'
expect(new_item.approval_uri).to be_nil
expect(new_item.position).to eq 3
end
end
context 'when item exists at a different position' do
let!(:collection_item) do
Fabricate(:collection_item, collection:, uri: featured_item_json['id'], position: 2)
end
it 'updates the position' do
expect { subject.call(collection, object, position:) }.to_not change(collection.collection_items, :count)
expect(collection_item.reload.position).to eq 3
end
end
context 'when an item exists for a local featured account' do
@@ -63,7 +79,7 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
let(:feature_authorization_uri) { ap_account_feature_authorization_url(collection_item.account_id, collection_item) }
it 'updates the URI of the existing record' do
expect { subject.call(collection, object) }.to_not change(collection.collection_items, :count)
expect { subject.call(collection, object, position:) }.to_not change(collection.collection_items, :count)
expect(collection_item.reload.uri).to eq 'https://other.example.com/featured_item/1'
end
end
@@ -87,7 +103,7 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do
it_behaves_like 'non-matching URIs'
it 'fetches the collection item' do
expect { subject.call(collection, object) }.to change(collection.collection_items, :count).by(1)
expect { subject.call(collection, object, position:) }.to change(collection.collection_items, :count).by(1)
expect(featured_item_request).to have_been_requested

Some files were not shown because too many files have changed in this diff Show More