Merge pull request #3396 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to ca9966ce2ff79dcac90b2feced65fa991534d53e
This commit is contained in:
Claire 2026-02-12 12:15:19 +01:00 committed by GitHub
commit 327936e113
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
180 changed files with 2153 additions and 1532 deletions

View File

@ -62,7 +62,7 @@ module Admin
def resource_params def resource_params
params params
.expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) .expect(user_role: [:name, :color, :highlighted, :position, :require_2fa, permissions_as_keys: []])
end end
end end
end end

View File

@ -61,19 +61,25 @@ class ApplicationController < ActionController::Base
return if request.referer.blank? return if request.referer.blank?
redirect_uri = URI(request.referer) redirect_uri = URI(request.referer)
return if redirect_uri.path.start_with?('/auth') return if redirect_uri.path.start_with?('/auth', '/settings/two_factor_authentication', '/settings/otp_authentication')
stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port
store_location_for(:user, stored_url) store_location_for(:user, stored_url)
end end
def mfa_setup_path(path_params = {})
settings_two_factor_authentication_methods_path(path_params)
end
def require_functional! def require_functional!
return if current_user.functional? return if current_user.functional?
respond_to do |format| respond_to do |format|
format.any do format.any do
if current_user.confirmed? if current_user.missing_2fa?
redirect_to mfa_setup_path
elsif current_user.confirmed?
redirect_to edit_user_registration_path redirect_to edit_user_registration_path
else else
redirect_to auth_setup_path redirect_to auth_setup_path
@ -85,6 +91,8 @@ class ApplicationController < ActionController::Base
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved? elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403 render json: { error: 'Your login is currently pending approval' }, status: 403
elsif current_user.missing_2fa?
render json: { error: 'Your account requires two-factor authentication' }, status: 403
elsif !current_user.functional? elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403 render json: { error: 'Your login is currently disabled' }, status: 403
end end

View File

@ -42,7 +42,7 @@ module ChallengableConcern
end end
def render_challenge def render_challenge
render 'auth/challenges/new', layout: 'auth' render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth'
end end
def challenge_passed? def challenge_passed?

View File

@ -24,4 +24,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
def truthy_param?(key) def truthy_param?(key)
ActiveModel::Type::Boolean.new.cast(params[key]) ActiveModel::Type::Boolean.new.cast(params[key])
end end
def mfa_setup_path
super({ oauth: true })
end
end end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class BaseController < ::Settings::BaseController
layout -> { truthy_param?(:oauth) ? 'modal' : 'admin' }
end
end
end

View File

@ -4,12 +4,15 @@ module Settings
module TwoFactorAuthentication module TwoFactorAuthentication
class ConfirmationsController < BaseController class ConfirmationsController < BaseController
include ChallengableConcern include ChallengableConcern
include Devise::Controllers::StoreLocation
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_challenge! before_action :require_challenge!
before_action :ensure_otp_secret before_action :ensure_otp_secret
helper_method :return_to_app_url
def new def new
prepare_two_factor_form prepare_two_factor_form
end end
@ -37,6 +40,10 @@ module Settings
private private
def return_to_app_url
stored_location_for(:user)
end
def confirmation_params def confirmation_params
params.expect(form_two_factor_confirmation: [:otp_attempt]) params.expect(form_two_factor_confirmation: [:otp_attempt])
end end

View File

@ -17,7 +17,7 @@ module Settings
def create def create
session[:new_otp_secret] = User.generate_otp_secret session[:new_otp_secret] = User.generate_otp_secret
redirect_to new_settings_two_factor_authentication_confirmation_path redirect_to new_settings_two_factor_authentication_confirmation_path(params.permit(:oauth))
end end
private private

View File

@ -22,7 +22,7 @@ module Settings
private private
def require_otp_enabled def require_otp_enabled
redirect_to settings_otp_authentication_path unless current_user.otp_enabled? redirect_to settings_otp_authentication_path(params.permit(:oauth)) unless current_user.otp_enabled?
end end
end end
end end

View File

@ -70,6 +70,10 @@ module JsonLdHelper
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end end
def supported_security_context?(json)
!json.nil? && equals_or_includes?(json['@context'], 'https://w3id.org/security/v1')
end
def unsupported_uri_scheme?(uri) def unsupported_uri_scheme?(uri)
uri.nil? || !uri.start_with?('http://', 'https://') uri.nil? || !uri.start_with?('http://', 'https://')
end end

View File

@ -6,7 +6,10 @@ import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
import { unreblog, reblog } from './interactions_typed'; import { unreblog, reblog } from './interactions_typed';
import { openModal } from './modal'; import { openModal } from './modal';
import { timelineExpandPinnedFromStatus } from './timelines_typed'; import {
insertPinnedStatusIntoTimelines,
removePinnedStatusFromTimelines,
} from './timelines_typed';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
@ -369,7 +372,7 @@ export function pin(status) {
api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(pinSuccess(status)); dispatch(pinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(insertPinnedStatusIntoTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(pinFail(status, error)); dispatch(pinFail(status, error));
}); });
@ -408,7 +411,7 @@ export function unpin (status) {
api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(unpinSuccess(status)); dispatch(unpinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(removePinnedStatusFromTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(unpinFail(status, error)); dispatch(unpinFail(status, error));
}); });

View File

@ -7,8 +7,8 @@ import type { Status } from '../models/status';
import { createAppThunk } from '../store/typed_functions'; import { createAppThunk } from '../store/typed_functions';
import { import {
expandAccountFeaturedTimeline,
expandTimeline, expandTimeline,
insertIntoTimeline,
TIMELINE_NON_STATUS_MARKERS, TIMELINE_NON_STATUS_MARKERS,
} from './timelines'; } from './timelines';
@ -173,9 +173,13 @@ export function parseTimelineKey(key: string): TimelineParams | null {
return null; return null;
} }
export function isTimelineKeyPinned(key: string) { export function isTimelineKeyPinned(key: string, accountId?: string) {
const parsedKey = parseTimelineKey(key); const parsedKey = parseTimelineKey(key);
return parsedKey?.type === 'account' && parsedKey.pinned; const isPinned = parsedKey?.type === 'account' && parsedKey.pinned;
if (!accountId || !isPinned) {
return isPinned;
}
return parsedKey.userId === accountId;
} }
export function isNonStatusId(value: unknown) { export function isNonStatusId(value: unknown) {
@ -199,52 +203,71 @@ export const timelineDelete = createAction<{
reblogOf: string | null; reblogOf: string | null;
}>('timelines/delete'); }>('timelines/delete');
export const timelineExpandPinnedFromStatus = createAppThunk( export const timelineDeleteStatus = createAction<{
statusId: string;
timelineKey: string;
}>('timelines/deleteStatus');
export const insertPinnedStatusIntoTimelines = createAppThunk(
(status: Status, { dispatch, getState }) => { (status: Status, { dispatch, getState }) => {
const accountId = status.getIn(['account', 'id']) as string; const currentAccountId = getState().meta.get('me', null) as string | null;
if (!accountId) { if (!currentAccountId) {
return; return;
} }
// Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. const tags =
const timelines = getState().timelines as ImmutableMap<string, unknown>; (
if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { status.get('tags') as
return;
}
void dispatch(
expandTimelineByParams({
type: 'account',
userId: accountId,
pinned: true,
}),
);
void dispatch(expandAccountFeaturedTimeline(accountId));
// Iterate over tags and clear those too.
const tags = status.get('tags') as
| ImmutableList<ImmutableMap<'name', string>> // We only care about the tag name. | ImmutableList<ImmutableMap<'name', string>> // We only care about the tag name.
| undefined; | undefined
if (!tags) { )
return; ?.map((tag) => tag.get('name') as string)
.toArray() ?? [];
const timelines = getState().timelines as ImmutableMap<string, unknown>;
const accountTimelines = timelines.filter((_, key) => {
if (!key.startsWith(`account:${currentAccountId}:`)) {
return false;
} }
tags.forEach((tag) => { const parsed = parseTimelineKey(key);
const tagName = tag.get('name'); const isPinned = parsed?.type === 'account' && parsed.pinned;
if (!tagName) { if (!isPinned) {
return; return false;
} }
void dispatch( return !parsed.tagged || tags.includes(parsed.tagged);
expandTimelineByParams({ });
type: 'account',
userId: accountId, accountTimelines.forEach((_, key) => {
pinned: true, dispatch(insertIntoTimeline(key, status.get('id') as string, 0));
tagged: tagName, });
}), },
); );
void dispatch(
expandAccountFeaturedTimeline(accountId, { tagged: tagName }), export const removePinnedStatusFromTimelines = createAppThunk(
); (status: Status, { dispatch, getState }) => {
const currentAccountId = getState().meta.get('me', null) as string | null;
if (!currentAccountId) {
return;
}
const statusId = status.get('id') as string;
const timelines = getState().timelines as ImmutableMap<
string,
ImmutableMap<'items' | 'pendingItems', ImmutableList<string>>
>;
timelines.forEach((timeline, key) => {
if (!isTimelineKeyPinned(key, currentAccountId)) {
return;
}
if (
timeline.get('items')?.includes(statusId) ||
timeline.get('pendingItems')?.includes(statusId)
) {
dispatch(timelineDeleteStatus({ statusId, timelineKey: key }));
}
}); });
}, },
); );

View File

@ -26,7 +26,7 @@ export const ExitAnimationWrapper: React.FC<{
* Render prop that provides the nested component with the `delayedIsActive` flag * Render prop that provides the nested component with the `delayedIsActive` flag
*/ */
children: (delayedIsActive: boolean) => React.ReactNode; children: (delayedIsActive: boolean) => React.ReactNode;
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { }> = ({ isActive, delayMs = 500, withEntryDelay, children }) => {
const [delayedIsActive, setDelayedIsActive] = useState( const [delayedIsActive, setDelayedIsActive] = useState(
isActive && !withEntryDelay, isActive && !withEntryDelay,
); );

View File

@ -1,5 +1,5 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -36,12 +36,26 @@ TextAreaField.displayName = 'TextAreaField';
export const TextArea = forwardRef< export const TextArea = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'> ComponentPropsWithoutRef<'textarea'>
>(({ className, ...otherProps }, ref) => ( >(({ className, onKeyDown, ...otherProps }, ref) => {
const handleSubmitHotkey = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(e);
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
const targetForm = e.currentTarget.form;
targetForm?.requestSubmit();
}
},
[onKeyDown],
);
return (
<textarea <textarea
{...otherProps} {...otherProps}
onKeyDown={handleSubmitHotkey}
className={classNames(className, classes.input)} className={classNames(className, classes.input)}
ref={ref} ref={ref}
/> />
)); );
});
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';

View File

@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import ColumnHeader from '../../../components/column_header';
const messages = defineMessages({
profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
});
class ProfileColumnHeader extends PureComponent {
static propTypes = {
onClick: PropTypes.func,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
render() {
const { onClick, intl, multiColumn } = this.props;
return (
<ColumnHeader
icon='user-circle'
iconComponent={PersonIcon}
title={intl.formatMessage(messages.profile)}
onClick={onClick}
showBackButton
multiColumn={multiColumn}
/>
);
}
}
export default injectIntl(ProfileColumnHeader);

View File

@ -0,0 +1,34 @@
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import { ColumnHeader } from '../../../components/column_header';
const messages = defineMessages({
profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
});
interface ProfileColumnHeaderProps {
onClick: () => void;
multiColumn: boolean;
}
export const ProfileColumnHeader: FC<ProfileColumnHeaderProps> = ({
onClick,
multiColumn,
}) => {
const intl = useIntl();
return (
<ColumnHeader
icon='user-circle'
iconComponent={PersonIcon}
title={intl.formatMessage(messages.profile)}
onClick={onClick}
showBackButton
multiColumn={multiColumn}
/>
);
};

View File

@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import { ProfileColumnHeader } from 'flavours/glitch/features/account/components/profile_column_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts';

View File

@ -7,7 +7,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useStorage } from '@/flavours/glitch/hooks/useStorage'; import { useStorageState } from '@/flavours/glitch/hooks/useStorage';
interface AccountTimelineContextValue { interface AccountTimelineContextValue {
accountId: string; accountId: string;
@ -26,30 +26,34 @@ export const AccountTimelineProvider: FC<{
accountId: string; accountId: string;
children: ReactNode; children: ReactNode;
}> = ({ accountId, children }) => { }> = ({ accountId, children }) => {
const { getItem, setItem } = useStorage({ const storageOptions = {
type: 'session', type: 'session',
prefix: `filters-${accountId}:`, prefix: `filters-${accountId}:`,
}); } as const;
const [boosts, setBoosts] = useState(
() => (getItem('boosts') === '0' ? false : true), // Default to enabled. const [boosts, setBoosts] = useStorageState<boolean>(
'boosts',
true,
storageOptions,
); );
const [replies, setReplies] = useState(() =>
getItem('replies') === '1' ? true : false, const [replies, setReplies] = useStorageState<boolean>(
'replies',
false,
storageOptions,
); );
const handleSetBoosts = useCallback( const handleSetBoosts = useCallback(
(value: boolean) => { (value: boolean) => {
setBoosts(value); setBoosts(value);
setItem('boosts', value ? '1' : '0');
}, },
[setBoosts, setItem], [setBoosts],
); );
const handleSetReplies = useCallback( const handleSetReplies = useCallback(
(value: boolean) => { (value: boolean) => {
setReplies(value); setReplies(value);
setItem('replies', value ? '1' : '0');
}, },
[setReplies, setItem], [setReplies],
); );
const [showAllPinned, setShowAllPinned] = useState(false); const [showAllPinned, setShowAllPinned] = useState(false);

View File

@ -0,0 +1,64 @@
import type { FC, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint';
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
import type { Account } from '@/flavours/glitch/models/account';
import { RemoteHint } from './remote';
interface BaseEmptyMessageProps {
account?: Account;
defaultMessage: ReactNode;
}
export type EmptyMessageProps = Omit<BaseEmptyMessageProps, 'defaultMessage'>;
export const BaseEmptyMessage: FC<BaseEmptyMessageProps> = ({
account,
defaultMessage,
}) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(account?.id);
if (!account) {
return null;
}
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
}
if (hidden) {
return <LimitedAccountHint accountId={account.id} />;
}
if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
}
if (account.hide_collections) {
return (
<FormattedMessage
id='empty_column.account_hides_collections'
defaultMessage='This user has chosen to not make this information available'
/>
);
}
const domain = account.acct.split('@')[1];
if (domain) {
return <RemoteHint domain={domain} url={account.url} />;
}
return defaultMessage;
};

View File

@ -0,0 +1,113 @@
import { useCallback, useMemo, useRef } from 'react';
import type { FC, ReactNode } from 'react';
import { Account } from '@/flavours/glitch/components/account';
import type { ColumnRef } from '@/flavours/glitch/components/column';
import { Column } from '@/flavours/glitch/components/column';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import ScrollableList from '@/flavours/glitch/components/scrollable_list';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
import { ProfileColumnHeader } from '../../account/components/profile_column_header';
import { AccountHeader } from '../../account_timeline/components/account_header';
import { RemoteHint } from './remote';
export interface AccountList {
hasMore: boolean;
isLoading: boolean;
items: string[];
}
interface AccountListProps {
accountId?: string | null;
append?: ReactNode;
emptyMessage: ReactNode;
footer?: ReactNode;
list?: AccountList | null;
loadMore: () => void;
prependAccountId?: string | null;
scrollKey: string;
}
export const AccountList: FC<AccountListProps> = ({
accountId,
append,
emptyMessage,
footer,
list,
loadMore,
prependAccountId,
scrollKey,
}) => {
const account = useAccount(accountId);
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const children = useMemo(() => {
if (forceEmptyState) {
return [];
}
const children =
list?.items.map((followerId) => (
<Account key={followerId} id={followerId} />
)) ?? [];
if (prependAccountId) {
children.unshift(
<Account key={prependAccountId} id={prependAccountId} minimal />,
);
}
return children;
}, [prependAccountId, list, forceEmptyState]);
const columnRef = useRef<ColumnRef>(null);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const { multiColumn } = useLayout();
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || !account) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const domain = account.acct.split('@')[1];
return (
<Column ref={columnRef}>
<ProfileColumnHeader
onClick={handleHeaderClick}
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey={scrollKey}
hasMore={!forceEmptyState && list?.hasMore}
isLoading={list?.isLoading ?? true}
onLoadMore={loadMore}
prepend={<AccountHeader accountId={accountId} hideTabs />}
alwaysPrepend
append={append ?? <RemoteHint domain={domain} url={account.url} />}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
footer={footer}
>
{children}
</ScrollableList>
</Column>
);
};

View File

@ -0,0 +1,32 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { TimelineHint } from '@/flavours/glitch/components/timeline_hint';
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
domain,
url,
}) => {
if (!domain) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.followers_may_be_missing'
defaultMessage='Followers for this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_followers'
defaultMessage='See more followers on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View File

@ -1,191 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Account } from 'flavours/glitch/components/account';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store';
import {
lookupAccount,
fetchAccount,
fetchFollowers,
expandFollowers,
} from '../../actions/accounts';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import ProfileColumnHeader from '../account/components/profile_column_header';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
};
};
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.followers_may_be_missing' defaultMessage='Followers for this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_followers' defaultMessage='See more followers on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class Followers extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(fetchFollowers(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowers(this.props.accountId));
}, 300, { leading: true });
setRef = c => {
this.column = c;
};
handleHeaderClick = () => {
this.column.scrollTop();
};
render () {
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage;
const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column ref={this.setRef}>
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<ScrollableList
scrollKey='followers'
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<Account key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
}
}
export default connect(mapStateToProps)(Followers);

View File

@ -0,0 +1,93 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import {
expandFollowers,
fetchFollowers,
} from '@/flavours/glitch/actions/accounts';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import type { EmptyMessageProps } from './components/empty';
import { BaseEmptyMessage } from './components/empty';
import { AccountList } from './components/list';
const Followers: FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
const currentAccountId = useAppSelector(
(state) => (state.meta.get('me') as string | null) ?? null,
);
const followerList = useAppSelector((state) =>
selectUserListWithoutMe(state, 'followers', accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!followerList && accountId) {
dispatch(fetchFollowers(accountId));
}
}, [accountId, dispatch, followerList]);
const loadMore = useDebouncedCallback(
() => {
if (accountId) {
dispatch(expandFollowers(accountId));
}
},
300,
{ leading: true },
);
const relationship = useRelationship(accountId);
const followerId = relationship?.following ? currentAccountId : null;
const followersExceptMeHidden = !!(
account?.hide_collections &&
followerList?.items.length === 0 &&
followerId
);
const footer = followersExceptMeHidden && (
<div className='empty-column-indicator'>
<FormattedMessage
id='followers.hide_other_followers'
defaultMessage='This user has chosen to not make their other followers visible'
/>
</div>
);
return (
<AccountList
accountId={accountId}
footer={footer}
emptyMessage={<EmptyMessage account={account} />}
list={followerList}
loadMore={loadMore}
prependAccountId={followerId}
scrollKey='followers'
/>
);
};
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
<BaseEmptyMessage
{...props}
defaultMessage={
<FormattedMessage
id='account.followers.empty'
defaultMessage='No one follows this user yet.'
/>
}
/>
);
// eslint-disable-next-line import/no-default-export -- Used by async components.
export default Followers;

View File

@ -0,0 +1,32 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { TimelineHint } from '@/flavours/glitch/components/timeline_hint';
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
domain,
url,
}) => {
if (!domain) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.follows_may_be_missing'
defaultMessage='Follows for this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_follows'
defaultMessage='See more follows on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View File

@ -1,191 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Account } from 'flavours/glitch/components/account';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store';
import {
lookupAccount,
fetchAccount,
fetchFollowing,
expandFollowing,
} from '../../actions/accounts';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import ProfileColumnHeader from '../account/components/profile_column_header';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
};
};
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.follows_may_be_missing' defaultMessage='Follows for this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_follows' defaultMessage='See more follows on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class Following extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(fetchFollowing(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowing(this.props.accountId));
}, 300, { leading: true });
setRef = c => {
this.column = c;
};
handleHeaderClick = () => {
this.column.scrollTop();
};
render () {
const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage;
const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
}
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column ref={this.setRef}>
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<ScrollableList
scrollKey='following'
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<Account key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
}
}
export default connect(mapStateToProps)(Following);

View File

@ -0,0 +1,97 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import {
expandFollowing,
fetchFollowing,
} from '@/flavours/glitch/actions/accounts';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import type { EmptyMessageProps } from '../followers/components/empty';
import { BaseEmptyMessage } from '../followers/components/empty';
import { AccountList } from '../followers/components/list';
import { RemoteHint } from './components/remote';
const Followers: FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
const currentAccountId = useAppSelector(
(state) => (state.meta.get('me') as string | null) ?? null,
);
const followingList = useAppSelector((state) =>
selectUserListWithoutMe(state, 'following', accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!followingList && accountId) {
dispatch(fetchFollowing(accountId));
}
}, [accountId, dispatch, followingList]);
const loadMore = useDebouncedCallback(
() => {
if (accountId) {
dispatch(expandFollowing(accountId));
}
},
300,
{ leading: true },
);
const relationship = useRelationship(accountId);
const followedId = relationship?.followed_by ? currentAccountId : null;
const followingExceptMeHidden = !!(
account?.hide_collections &&
followingList?.items.length === 0 &&
followedId
);
const footer = followingExceptMeHidden && (
<div className='empty-column-indicator'>
<FormattedMessage
id='following.hide_other_following'
defaultMessage='This user has chosen to not make the rest of who they follow visible'
/>
</div>
);
const domain = account?.acct.split('@')[1];
return (
<AccountList
accountId={accountId}
append={domain && <RemoteHint domain={domain} url={account.url} />}
emptyMessage={<EmptyMessage account={account} />}
footer={footer}
list={followingList}
loadMore={loadMore}
prependAccountId={currentAccountId}
scrollKey='following'
/>
);
};
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
<BaseEmptyMessage
{...props}
defaultMessage={
<FormattedMessage
id='account.follows.empty'
defaultMessage="This user doesn't follow anyone yet."
/>
}
/>
);
// eslint-disable-next-line import/no-default-export -- Used by async components.
export default Followers;

View File

@ -8,6 +8,7 @@ export const useLayout = () => {
return { return {
singleColumn: layout === 'single-column' || layout === 'mobile', singleColumn: layout === 'single-column' || layout === 'mobile',
multiColumn: layout === 'multi-column',
layout, layout,
}; };
}; };

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { fetchRelationships } from '../actions/accounts';
import { useAppDispatch, useAppSelector } from '../store';
export function useRelationship(accountId?: string | null) {
const relationship = useAppSelector((state) =>
accountId ? state.relationships.get(accountId) : null,
);
const dispatch = useAppDispatch();
useEffect(() => {
if (accountId && !relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
return relationship;
}

View File

@ -1,9 +1,14 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
interface StorageOptions {
type?: 'local' | 'session';
prefix?: string;
}
export function useStorage({ export function useStorage({
type = 'local', type = 'local',
prefix = '', prefix = '',
}: { type?: 'local' | 'session'; prefix?: string } = {}) { }: StorageOptions = {}) {
const storageType = type === 'local' ? 'localStorage' : 'sessionStorage'; const storageType = type === 'local' ? 'localStorage' : 'sessionStorage';
const isAvailable = useMemo( const isAvailable = useMemo(
() => storageAvailable(storageType), () => storageAvailable(storageType),
@ -23,6 +28,7 @@ export function useStorage({
}, },
[isAvailable, storageType, prefix], [isAvailable, storageType, prefix],
); );
const setItem = useCallback( const setItem = useCallback(
(key: string, value: string) => { (key: string, value: string) => {
if (!isAvailable) { if (!isAvailable) {
@ -35,13 +41,52 @@ export function useStorage({
[isAvailable, storageType, prefix], [isAvailable, storageType, prefix],
); );
const removeItem = useCallback(
(key: string) => {
if (!isAvailable) {
return;
}
try {
window[storageType].removeItem(prefix ? `${prefix};${key}` : key);
} catch {}
},
[isAvailable, storageType, prefix],
);
return { return {
isAvailable, isAvailable,
getItem, getItem,
setItem, setItem,
removeItem,
}; };
} }
export function useStorageState<T extends string | boolean>(
key: string,
initialState: T,
options?: StorageOptions,
) {
const { getItem, setItem, removeItem } = useStorage(options);
const [state, setState] = useState<T>(
() => (retrieveBooleanOrString(getItem(key)) as T | null) ?? initialState,
);
const handleSetState = useCallback(
(newValue: T) => {
setItem(key, castToString(newValue));
setState(newValue);
},
[key, setItem],
);
const removeState = useCallback(() => {
removeItem(key);
setState(initialState);
}, [initialState, key, removeItem]);
return [state, handleSetState, removeState] as const;
}
// Tests the storage availability for the given type. Taken from MDN: // Tests the storage availability for the given type. Taken from MDN:
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API // https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
export function storageAvailable(type: 'localStorage' | 'sessionStorage') { export function storageAvailable(type: 'localStorage' | 'sessionStorage') {
@ -62,3 +107,21 @@ export function storageAvailable(type: 'localStorage' | 'sessionStorage') {
); );
} }
} }
function castToString(value: string | boolean) {
if (typeof value === 'boolean') {
return value ? '1' : '0';
} else {
return value;
}
}
function retrieveBooleanOrString(value: string | null) {
if (value === '1') {
return true;
} else if (value === '0') {
return false;
} else {
return value;
}
}

View File

@ -292,7 +292,7 @@ function shouldMarkNewNotificationsAsRead(
function updateLastReadId( function updateLastReadId(
state: NotificationGroupsState, state: NotificationGroupsState,
group: NotificationGroup | undefined = undefined, group?: NotificationGroup,
) { ) {
if (shouldMarkNewNotificationsAsRead(state)) { if (shouldMarkNewNotificationsAsRead(state)) {
group = group ?? state.groups.find(isNotificationGroup); group = group ?? state.groups.find(isNotificationGroup);

View File

@ -20,7 +20,12 @@ import {
TIMELINE_GAP, TIMELINE_GAP,
disconnectTimeline, disconnectTimeline,
} from '../actions/timelines'; } from '../actions/timelines';
import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; import {
timelineDelete,
timelineDeleteStatus,
isTimelineKeyPinned,
isNonStatusId,
} from '../actions/timelines_typed';
import { compareId } from '../compare_id'; import { compareId } from '../compare_id';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -151,6 +156,11 @@ const deleteStatus = (state, id, references, exclude_account = null) => {
return state; return state;
}; };
const deleteStatusFromTimeline = (state, statusId, timelineKey) => {
const helper = list => list.filterNot((status) => status === statusId);
return state.updateIn([timelineKey, 'items'], helper).updateIn([timelineKey, 'pendingItems'], helper);
}
const clearTimeline = (state, timeline) => { const clearTimeline = (state, timeline) => {
return state.set(timeline, initialTimeline); return state.set(timeline, initialTimeline);
}; };
@ -206,25 +216,12 @@ export default function timelines(state = initialState, action) {
return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems);
case TIMELINE_UPDATE: case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered); return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems, action.filtered);
case timelineDelete.type:
return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf);
case TIMELINE_CLEAR: case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline); return clearTimeline(state, action.timeline);
case blockAccountSuccess.type:
case muteAccountSuccess.type:
return filterTimelines(state, action.payload.relationship, action.payload.statuses);
case unfollowAccountSuccess.type:
return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT: case TIMELINE_CONNECT:
return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems)); return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems));
case disconnectTimeline.type:
return state.update(
action.payload.timeline,
initialTimeline,
map => map.set('online', false).update(action.payload.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items),
);
case TIMELINE_MARK_AS_PARTIAL: case TIMELINE_MARK_AS_PARTIAL:
return state.update( return state.update(
action.timeline, action.timeline,
@ -244,6 +241,29 @@ export default function timelines(state = initialState, action) {
}) })
); );
default: default:
if (timelineDelete.match(action)) {
return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf);
} else if (timelineDeleteStatus.match(action)) {
return deleteStatusFromTimeline(state, action.payload.statusId, action.payload.timelineKey);
} else if (blockAccountSuccess.match(action) || muteAccountSuccess.match(action)) {
return filterTimelines(state, action.payload.relationship, action.payload.statuses);
} else if (unfollowAccountSuccess.match(action)) {
return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
} else if (disconnectTimeline.match(action)) {
return state.update(
action.payload.timeline,
initialTimeline,
(map) => map.set('online', false).update(
action.payload.usePendingItems
? 'pendingItems'
: 'items',
items => items.first()
? items.unshift(TIMELINE_GAP)
: items
),
);
}
return state; return state;
} }
} }

View File

@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap<string, unknown>) {
emptyList, emptyList,
) as ImmutableList<string>, ) as ImmutableList<string>,
items: timeline.get('items', emptyList) as ImmutableList<string>, items: timeline.get('items', emptyList) as ImmutableList<string>,
} as TimelineShape; } satisfies TimelineShape;
} }

View File

@ -0,0 +1,35 @@
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { createAppSelector } from '../store';
export const selectUserListWithoutMe = createAppSelector(
[
(state) => state.user_lists,
(state) => (state.meta.get('me') as string | null) ?? null,
(_state, listName: string) => listName,
(_state, _listName, accountId?: string | null) => accountId ?? null,
],
(lists, currentAccountId, listName, accountId) => {
if (!accountId || !listName) {
return null;
}
const list = lists.getIn([listName, accountId]) as
| ImmutableMap<string, unknown>
| undefined;
if (!list) {
return null;
}
// Returns the list except the current account.
return {
items: (
list.get('items', ImmutableList<string>()) as ImmutableList<string>
)
.filter((id) => id !== currentAccountId)
.toArray(),
isLoading: !!list.get('isLoading', true),
hasMore: !!list.get('hasMore', false),
};
},
);

View File

@ -6,7 +6,10 @@ import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
import { unreblog, reblog } from './interactions_typed'; import { unreblog, reblog } from './interactions_typed';
import { openModal } from './modal'; import { openModal } from './modal';
import { timelineExpandPinnedFromStatus } from './timelines_typed'; import {
insertPinnedStatusIntoTimelines,
removePinnedStatusFromTimelines,
} from './timelines_typed';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
@ -369,7 +372,7 @@ export function pin(status) {
api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(pinSuccess(status)); dispatch(pinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(insertPinnedStatusIntoTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(pinFail(status, error)); dispatch(pinFail(status, error));
}); });
@ -408,7 +411,7 @@ export function unpin (status) {
api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(unpinSuccess(status)); dispatch(unpinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(removePinnedStatusFromTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(unpinFail(status, error)); dispatch(unpinFail(status, error));
}); });

View File

@ -7,8 +7,8 @@ import type { Status } from '../models/status';
import { createAppThunk } from '../store/typed_functions'; import { createAppThunk } from '../store/typed_functions';
import { import {
expandAccountFeaturedTimeline,
expandTimeline, expandTimeline,
insertIntoTimeline,
TIMELINE_NON_STATUS_MARKERS, TIMELINE_NON_STATUS_MARKERS,
} from './timelines'; } from './timelines';
@ -173,9 +173,13 @@ export function parseTimelineKey(key: string): TimelineParams | null {
return null; return null;
} }
export function isTimelineKeyPinned(key: string) { export function isTimelineKeyPinned(key: string, accountId?: string) {
const parsedKey = parseTimelineKey(key); const parsedKey = parseTimelineKey(key);
return parsedKey?.type === 'account' && parsedKey.pinned; const isPinned = parsedKey?.type === 'account' && parsedKey.pinned;
if (!accountId || !isPinned) {
return isPinned;
}
return parsedKey.userId === accountId;
} }
export function isNonStatusId(value: unknown) { export function isNonStatusId(value: unknown) {
@ -199,52 +203,71 @@ export const timelineDelete = createAction<{
reblogOf: string | null; reblogOf: string | null;
}>('timelines/delete'); }>('timelines/delete');
export const timelineExpandPinnedFromStatus = createAppThunk( export const timelineDeleteStatus = createAction<{
statusId: string;
timelineKey: string;
}>('timelines/deleteStatus');
export const insertPinnedStatusIntoTimelines = createAppThunk(
(status: Status, { dispatch, getState }) => { (status: Status, { dispatch, getState }) => {
const accountId = status.getIn(['account', 'id']) as string; const currentAccountId = getState().meta.get('me', null) as string | null;
if (!accountId) { if (!currentAccountId) {
return; return;
} }
// Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. const tags =
const timelines = getState().timelines as ImmutableMap<string, unknown>; (
if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { status.get('tags') as
return;
}
void dispatch(
expandTimelineByParams({
type: 'account',
userId: accountId,
pinned: true,
}),
);
void dispatch(expandAccountFeaturedTimeline(accountId));
// Iterate over tags and clear those too.
const tags = status.get('tags') as
| ImmutableList<ImmutableMap<'name', string>> // We only care about the tag name. | ImmutableList<ImmutableMap<'name', string>> // We only care about the tag name.
| undefined; | undefined
if (!tags) { )
return; ?.map((tag) => tag.get('name') as string)
.toArray() ?? [];
const timelines = getState().timelines as ImmutableMap<string, unknown>;
const accountTimelines = timelines.filter((_, key) => {
if (!key.startsWith(`account:${currentAccountId}:`)) {
return false;
} }
tags.forEach((tag) => { const parsed = parseTimelineKey(key);
const tagName = tag.get('name'); const isPinned = parsed?.type === 'account' && parsed.pinned;
if (!tagName) { if (!isPinned) {
return; return false;
} }
void dispatch( return !parsed.tagged || tags.includes(parsed.tagged);
expandTimelineByParams({ });
type: 'account',
userId: accountId, accountTimelines.forEach((_, key) => {
pinned: true, dispatch(insertIntoTimeline(key, status.get('id') as string, 0));
tagged: tagName, });
}), },
); );
void dispatch(
expandAccountFeaturedTimeline(accountId, { tagged: tagName }), export const removePinnedStatusFromTimelines = createAppThunk(
); (status: Status, { dispatch, getState }) => {
const currentAccountId = getState().meta.get('me', null) as string | null;
if (!currentAccountId) {
return;
}
const statusId = status.get('id') as string;
const timelines = getState().timelines as ImmutableMap<
string,
ImmutableMap<'items' | 'pendingItems', ImmutableList<string>>
>;
timelines.forEach((timeline, key) => {
if (!isTimelineKeyPinned(key, currentAccountId)) {
return;
}
if (
timeline.get('items')?.includes(statusId) ||
timeline.get('pendingItems')?.includes(statusId)
) {
dispatch(timelineDeleteStatus({ statusId, timelineKey: key }));
}
}); });
}, },
); );

View File

@ -26,7 +26,7 @@ export const ExitAnimationWrapper: React.FC<{
* Render prop that provides the nested component with the `delayedIsActive` flag * Render prop that provides the nested component with the `delayedIsActive` flag
*/ */
children: (delayedIsActive: boolean) => React.ReactNode; children: (delayedIsActive: boolean) => React.ReactNode;
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { }> = ({ isActive, delayMs = 500, withEntryDelay, children }) => {
const [delayedIsActive, setDelayedIsActive] = useState( const [delayedIsActive, setDelayedIsActive] = useState(
isActive && !withEntryDelay, isActive && !withEntryDelay,
); );

View File

@ -1,5 +1,5 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -36,12 +36,26 @@ TextAreaField.displayName = 'TextAreaField';
export const TextArea = forwardRef< export const TextArea = forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'> ComponentPropsWithoutRef<'textarea'>
>(({ className, ...otherProps }, ref) => ( >(({ className, onKeyDown, ...otherProps }, ref) => {
const handleSubmitHotkey = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(e);
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
const targetForm = e.currentTarget.form;
targetForm?.requestSubmit();
}
},
[onKeyDown],
);
return (
<textarea <textarea
{...otherProps} {...otherProps}
onKeyDown={handleSubmitHotkey}
className={classNames(className, classes.input)} className={classNames(className, classes.input)}
ref={ref} ref={ref}
/> />
)); );
});
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';

View File

@ -7,7 +7,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useStorage } from '@/mastodon/hooks/useStorage'; import { useStorageState } from '@/mastodon/hooks/useStorage';
interface AccountTimelineContextValue { interface AccountTimelineContextValue {
accountId: string; accountId: string;
@ -26,30 +26,34 @@ export const AccountTimelineProvider: FC<{
accountId: string; accountId: string;
children: ReactNode; children: ReactNode;
}> = ({ accountId, children }) => { }> = ({ accountId, children }) => {
const { getItem, setItem } = useStorage({ const storageOptions = {
type: 'session', type: 'session',
prefix: `filters-${accountId}:`, prefix: `filters-${accountId}:`,
}); } as const;
const [boosts, setBoosts] = useState(
() => (getItem('boosts') === '0' ? false : true), // Default to enabled. const [boosts, setBoosts] = useStorageState<boolean>(
'boosts',
true,
storageOptions,
); );
const [replies, setReplies] = useState(() =>
getItem('replies') === '1' ? true : false, const [replies, setReplies] = useStorageState<boolean>(
'replies',
false,
storageOptions,
); );
const handleSetBoosts = useCallback( const handleSetBoosts = useCallback(
(value: boolean) => { (value: boolean) => {
setBoosts(value); setBoosts(value);
setItem('boosts', value ? '1' : '0');
}, },
[setBoosts, setItem], [setBoosts],
); );
const handleSetReplies = useCallback( const handleSetReplies = useCallback(
(value: boolean) => { (value: boolean) => {
setReplies(value); setReplies(value);
setItem('replies', value ? '1' : '0');
}, },
[setReplies, setItem], [setReplies],
); );
const [showAllPinned, setShowAllPinned] = useState(false); const [showAllPinned, setShowAllPinned] = useState(false);

View File

@ -0,0 +1,64 @@
import type { FC, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import type { Account } from '@/mastodon/models/account';
import { RemoteHint } from './remote';
interface BaseEmptyMessageProps {
account?: Account;
defaultMessage: ReactNode;
}
export type EmptyMessageProps = Omit<BaseEmptyMessageProps, 'defaultMessage'>;
export const BaseEmptyMessage: FC<BaseEmptyMessageProps> = ({
account,
defaultMessage,
}) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(account?.id);
if (!account) {
return null;
}
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
}
if (hidden) {
return <LimitedAccountHint accountId={account.id} />;
}
if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
}
if (account.hide_collections) {
return (
<FormattedMessage
id='empty_column.account_hides_collections'
defaultMessage='This user has chosen to not make this information available'
/>
);
}
const domain = account.acct.split('@')[1];
if (domain) {
return <RemoteHint domain={domain} url={account.url} />;
}
return defaultMessage;
};

View File

@ -0,0 +1,104 @@
import { useMemo } from 'react';
import type { FC, ReactNode } from 'react';
import { Account } from '@/mastodon/components/account';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import ScrollableList from '@/mastodon/components/scrollable_list';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { AccountHeader } from '../../account_timeline/components/account_header';
import { RemoteHint } from './remote';
export interface AccountList {
hasMore: boolean;
isLoading: boolean;
items: string[];
}
interface AccountListProps {
accountId?: string | null;
append?: ReactNode;
emptyMessage: ReactNode;
footer?: ReactNode;
list?: AccountList | null;
loadMore: () => void;
prependAccountId?: string | null;
scrollKey: string;
}
export const AccountList: FC<AccountListProps> = ({
accountId,
append,
emptyMessage,
footer,
list,
loadMore,
prependAccountId,
scrollKey,
}) => {
const account = useAccount(accountId);
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const children = useMemo(() => {
if (forceEmptyState) {
return [];
}
const children =
list?.items.map((followerId) => (
<Account key={followerId} id={followerId} />
)) ?? [];
if (prependAccountId) {
children.unshift(
<Account key={prependAccountId} id={prependAccountId} minimal />,
);
}
return children;
}, [prependAccountId, list, forceEmptyState]);
const { multiColumn } = useLayout();
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || !account) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const domain = account.acct.split('@')[1];
return (
<Column>
<ColumnBackButton />
<ScrollableList
scrollKey={scrollKey}
hasMore={!forceEmptyState && list?.hasMore}
isLoading={list?.isLoading ?? true}
onLoadMore={loadMore}
prepend={<AccountHeader accountId={accountId} hideTabs />}
alwaysPrepend
append={append ?? <RemoteHint domain={domain} url={account.url} />}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
footer={footer}
>
{children}
</ScrollableList>
</Column>
);
};

View File

@ -0,0 +1,32 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { TimelineHint } from '@/mastodon/components/timeline_hint';
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
domain,
url,
}) => {
if (!domain) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.followers_may_be_missing'
defaultMessage='Followers for this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_followers'
defaultMessage='See more followers on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View File

@ -1,187 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Account } from 'mastodon/components/account';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
import {
lookupAccount,
fetchAccount,
fetchFollowers,
expandFollowers,
} from '../../actions/accounts';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
};
};
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.followers_may_be_missing' defaultMessage='Followers for this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_followers' defaultMessage='See more followers on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class Followers extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(fetchFollowers(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowers(this.props.accountId));
}, 300, { leading: true });
render () {
const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage;
const forceEmptyState = blockedBy || suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>
<ColumnBackButton />
<ScrollableList
scrollKey='followers'
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{forceEmptyState ? [] : accountIds.map(id =>
<Account key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
}
}
export default connect(mapStateToProps)(Followers);

View File

@ -0,0 +1,90 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import { expandFollowers, fetchFollowers } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { EmptyMessageProps } from './components/empty';
import { BaseEmptyMessage } from './components/empty';
import { AccountList } from './components/list';
const Followers: FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
const currentAccountId = useAppSelector(
(state) => (state.meta.get('me') as string | null) ?? null,
);
const followerList = useAppSelector((state) =>
selectUserListWithoutMe(state, 'followers', accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!followerList && accountId) {
dispatch(fetchFollowers(accountId));
}
}, [accountId, dispatch, followerList]);
const loadMore = useDebouncedCallback(
() => {
if (accountId) {
dispatch(expandFollowers(accountId));
}
},
300,
{ leading: true },
);
const relationship = useRelationship(accountId);
const followerId = relationship?.following ? currentAccountId : null;
const followersExceptMeHidden = !!(
account?.hide_collections &&
followerList?.items.length === 0 &&
followerId
);
const footer = followersExceptMeHidden && (
<div className='empty-column-indicator'>
<FormattedMessage
id='followers.hide_other_followers'
defaultMessage='This user has chosen to not make their other followers visible'
/>
</div>
);
return (
<AccountList
accountId={accountId}
footer={footer}
emptyMessage={<EmptyMessage account={account} />}
list={followerList}
loadMore={loadMore}
prependAccountId={followerId}
scrollKey='followers'
/>
);
};
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
<BaseEmptyMessage
{...props}
defaultMessage={
<FormattedMessage
id='account.followers.empty'
defaultMessage='No one follows this user yet.'
/>
}
/>
);
// eslint-disable-next-line import/no-default-export -- Used by async components.
export default Followers;

View File

@ -0,0 +1,32 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { TimelineHint } from '@/mastodon/components/timeline_hint';
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
domain,
url,
}) => {
if (!domain) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.follows_may_be_missing'
defaultMessage='Follows for this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_follows'
defaultMessage='See more follows on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};

View File

@ -1,187 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Account } from 'mastodon/components/account';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
import {
lookupAccount,
fetchAccount,
fetchFollowing,
expandFollowing,
} from '../../actions/accounts';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
hidden: getAccountHidden(state, accountId),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
};
};
const RemoteHint = ({ accountId, url }) => {
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
const domain = acct ? acct.split('@')[1] : undefined;
return (
<TimelineHint
url={url}
message={<FormattedMessage id='hints.profiles.follows_may_be_missing' defaultMessage='Follows for this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_follows' defaultMessage='See more follows on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class Following extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(fetchFollowing(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandFollowing(this.props.accountId));
}, 300, { leading: true });
render () {
const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage;
const forceEmptyState = blockedBy || suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (hideCollections && accountIds.isEmpty()) {
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
}
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>
<ColumnBackButton />
<ScrollableList
scrollKey='following'
hasMore={!forceEmptyState && hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{forceEmptyState ? [] : accountIds.map(id =>
<Account key={id} id={id} />,
)}
</ScrollableList>
</Column>
);
}
}
export default connect(mapStateToProps)(Following);

View File

@ -0,0 +1,94 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDebouncedCallback } from 'use-debounce';
import { expandFollowing, fetchFollowing } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { EmptyMessageProps } from '../followers/components/empty';
import { BaseEmptyMessage } from '../followers/components/empty';
import { AccountList } from '../followers/components/list';
import { RemoteHint } from './components/remote';
const Followers: FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
const currentAccountId = useAppSelector(
(state) => (state.meta.get('me') as string | null) ?? null,
);
const followingList = useAppSelector((state) =>
selectUserListWithoutMe(state, 'following', accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!followingList && accountId) {
dispatch(fetchFollowing(accountId));
}
}, [accountId, dispatch, followingList]);
const loadMore = useDebouncedCallback(
() => {
if (accountId) {
dispatch(expandFollowing(accountId));
}
},
300,
{ leading: true },
);
const relationship = useRelationship(accountId);
const followedId = relationship?.followed_by ? currentAccountId : null;
const followingExceptMeHidden = !!(
account?.hide_collections &&
followingList?.items.length === 0 &&
followedId
);
const footer = followingExceptMeHidden && (
<div className='empty-column-indicator'>
<FormattedMessage
id='following.hide_other_following'
defaultMessage='This user has chosen to not make the rest of who they follow visible'
/>
</div>
);
const domain = account?.acct.split('@')[1];
return (
<AccountList
accountId={accountId}
append={domain && <RemoteHint domain={domain} url={account.url} />}
emptyMessage={<EmptyMessage account={account} />}
footer={footer}
list={followingList}
loadMore={loadMore}
prependAccountId={currentAccountId}
scrollKey='following'
/>
);
};
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
<BaseEmptyMessage
{...props}
defaultMessage={
<FormattedMessage
id='account.follows.empty'
defaultMessage="This user doesn't follow anyone yet."
/>
}
/>
);
// eslint-disable-next-line import/no-default-export -- Used by async components.
export default Followers;

View File

@ -8,6 +8,7 @@ export const useLayout = () => {
return { return {
singleColumn: layout === 'single-column' || layout === 'mobile', singleColumn: layout === 'single-column' || layout === 'mobile',
multiColumn: layout === 'multi-column',
layout, layout,
}; };
}; };

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { fetchRelationships } from '../actions/accounts';
import { useAppDispatch, useAppSelector } from '../store';
export function useRelationship(accountId?: string | null) {
const relationship = useAppSelector((state) =>
accountId ? state.relationships.get(accountId) : null,
);
const dispatch = useAppDispatch();
useEffect(() => {
if (accountId && !relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
return relationship;
}

View File

@ -1,9 +1,14 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
interface StorageOptions {
type?: 'local' | 'session';
prefix?: string;
}
export function useStorage({ export function useStorage({
type = 'local', type = 'local',
prefix = '', prefix = '',
}: { type?: 'local' | 'session'; prefix?: string } = {}) { }: StorageOptions = {}) {
const storageType = type === 'local' ? 'localStorage' : 'sessionStorage'; const storageType = type === 'local' ? 'localStorage' : 'sessionStorage';
const isAvailable = useMemo( const isAvailable = useMemo(
() => storageAvailable(storageType), () => storageAvailable(storageType),
@ -23,6 +28,7 @@ export function useStorage({
}, },
[isAvailable, storageType, prefix], [isAvailable, storageType, prefix],
); );
const setItem = useCallback( const setItem = useCallback(
(key: string, value: string) => { (key: string, value: string) => {
if (!isAvailable) { if (!isAvailable) {
@ -35,13 +41,52 @@ export function useStorage({
[isAvailable, storageType, prefix], [isAvailable, storageType, prefix],
); );
const removeItem = useCallback(
(key: string) => {
if (!isAvailable) {
return;
}
try {
window[storageType].removeItem(prefix ? `${prefix};${key}` : key);
} catch {}
},
[isAvailable, storageType, prefix],
);
return { return {
isAvailable, isAvailable,
getItem, getItem,
setItem, setItem,
removeItem,
}; };
} }
export function useStorageState<T extends string | boolean>(
key: string,
initialState: T,
options?: StorageOptions,
) {
const { getItem, setItem, removeItem } = useStorage(options);
const [state, setState] = useState<T>(
() => (retrieveBooleanOrString(getItem(key)) as T | null) ?? initialState,
);
const handleSetState = useCallback(
(newValue: T) => {
setItem(key, castToString(newValue));
setState(newValue);
},
[key, setItem],
);
const removeState = useCallback(() => {
removeItem(key);
setState(initialState);
}, [initialState, key, removeItem]);
return [state, handleSetState, removeState] as const;
}
// Tests the storage availability for the given type. Taken from MDN: // Tests the storage availability for the given type. Taken from MDN:
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API // https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
export function storageAvailable(type: 'localStorage' | 'sessionStorage') { export function storageAvailable(type: 'localStorage' | 'sessionStorage') {
@ -62,3 +107,21 @@ export function storageAvailable(type: 'localStorage' | 'sessionStorage') {
); );
} }
} }
function castToString(value: string | boolean) {
if (typeof value === 'boolean') {
return value ? '1' : '0';
} else {
return value;
}
}
function retrieveBooleanOrString(value: string | null) {
if (value === '1') {
return true;
} else if (value === '0') {
return false;
} else {
return value;
}
}

View File

@ -14,7 +14,10 @@
"about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}", "about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}",
"about.rules": "قواعد الخادم", "about.rules": "قواعد الخادم",
"account.account_note_header": "ملاحظة شخصية", "account.account_note_header": "ملاحظة شخصية",
"account.activity": "النشاط",
"account.add_note": "إضافة ملاحظة شخصية",
"account.add_or_remove_from_list": "الإضافة أو الإزالة من القائمة", "account.add_or_remove_from_list": "الإضافة أو الإزالة من القائمة",
"account.badges.admin": "مدير",
"account.badges.bot": "آلي", "account.badges.bot": "آلي",
"account.badges.group": "فريق", "account.badges.group": "فريق",
"account.block": "احجب @{name}", "account.block": "احجب @{name}",
@ -38,8 +41,18 @@
"account.featured.hashtags": "هاشتاقات", "account.featured.hashtags": "هاشتاقات",
"account.featured_tags.last_status_at": "آخر منشور في {date}", "account.featured_tags.last_status_at": "آخر منشور في {date}",
"account.featured_tags.last_status_never": "لا توجد رسائل", "account.featured_tags.last_status_never": "لا توجد رسائل",
"account.fields.scroll_next": "أظْهِر التَّالي",
"account.fields.scroll_prev": "أظْهِر السَّابِق",
"account.filters.all": "جميع الأنشطة",
"account.filters.posts_only": "منشورات",
"account.filters.posts_replies": "المنشورات والردود",
"account.filters.replies_toggle": "اعرض الردود",
"account.follow": "متابعة", "account.follow": "متابعة",
"account.follow_back": "تابعه بالمثل", "account.follow_back": "تابعه بالمثل",
"account.follow_back_short": "تابعه بالمثل",
"account.follow_request": "طلب المتابعة",
"account.follow_request_cancel": "إلغاء الطلب",
"account.follow_request_cancel_short": "إلغاء",
"account.followers": "مُتابِعون", "account.followers": "مُتابِعون",
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.", "account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}", "account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
@ -57,6 +70,7 @@
"account.locked_info": "تم ضبط حالة خصوصية هذا الحساب على أنه مؤمّن. إذ يراجع صاحبه يدويًا من يُسمح له بالمتابعة.", "account.locked_info": "تم ضبط حالة خصوصية هذا الحساب على أنه مؤمّن. إذ يراجع صاحبه يدويًا من يُسمح له بالمتابعة.",
"account.media": "وسائط", "account.media": "وسائط",
"account.mention": "أذكُر @{name}", "account.mention": "أذكُر @{name}",
"account.menu.copy": "نسخ الرابط",
"account.moved_to": "أشار {name} إلى أن حسابه الجديد الآن:", "account.moved_to": "أشار {name} إلى أن حسابه الجديد الآن:",
"account.mute": "أكتم @{name}", "account.mute": "أكتم @{name}",
"account.mute_notifications_short": "كتم الإشعارات", "account.mute_notifications_short": "كتم الإشعارات",
@ -65,6 +79,11 @@
"account.muting": "مكتوم", "account.muting": "مكتوم",
"account.mutual": "أنتم تتابعون بعضكم البعض", "account.mutual": "أنتم تتابعون بعضكم البعض",
"account.no_bio": "لم يتم تقديم وصف.", "account.no_bio": "لم يتم تقديم وصف.",
"account.node_modal.field_label": "ملاحظة شخصية",
"account.node_modal.save": "حفظ",
"account.node_modal.title": "إضافة ملاحظة شخصية",
"account.note.edit_button": "تعديل",
"account.note.title": "ملاحظة شخصية (مرئية لك فقط)",
"account.open_original_page": "افتح الصفحة الأصلية", "account.open_original_page": "افتح الصفحة الأصلية",
"account.posts": "منشورات", "account.posts": "منشورات",
"account.posts_with_replies": "المنشورات والرُدود", "account.posts_with_replies": "المنشورات والرُدود",
@ -107,11 +126,16 @@
"alt_text_modal.describe_for_people_with_visual_impairments": "قم بوصفها للأشخاص ذوي الإعاقة البصرية…", "alt_text_modal.describe_for_people_with_visual_impairments": "قم بوصفها للأشخاص ذوي الإعاقة البصرية…",
"alt_text_modal.done": "تمّ", "alt_text_modal.done": "تمّ",
"announcement.announcement": "إعلان", "announcement.announcement": "إعلان",
"annual_report.summary.archetype.replier.name": "الفراشة",
"annual_report.summary.close": "اغلق",
"annual_report.summary.copy_link": "نسخ الرابط",
"annual_report.summary.most_used_app.most_used_app": "التطبيق الأكثر استخداماً", "annual_report.summary.most_used_app.most_used_app": "التطبيق الأكثر استخداماً",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "الهاشتاق الأكثر استخداماً", "annual_report.summary.most_used_hashtag.most_used_hashtag": "الهاشتاق الأكثر استخداماً",
"annual_report.summary.new_posts.new_posts": "المنشورات الجديدة", "annual_report.summary.new_posts.new_posts": "المنشورات الجديدة",
"annual_report.summary.percentile.text": "<topLabel>هذا يجعلك من بين أكثر </topLabel><percentage></percentage><bottomLabel>مستخدمي {domain} نشاطاً </bottomLabel>", "annual_report.summary.percentile.text": "<topLabel>هذا يجعلك من بين أكثر </topLabel><percentage></percentage><bottomLabel>مستخدمي {domain} نشاطاً </bottomLabel>",
"annual_report.summary.percentile.we_wont_tell_bernie": "سيبقى هذا الأمر بيننا.", "annual_report.summary.percentile.we_wont_tell_bernie": "سيبقى هذا الأمر بيننا.",
"annual_report.summary.share_elsewhere": "شاركها في مكان آخر",
"annual_report.summary.share_on_mastodon": "شاركها على ماستدون",
"attachments_list.unprocessed": "(غير معالَج)", "attachments_list.unprocessed": "(غير معالَج)",
"audio.hide": "إخفاء المقطع الصوتي", "audio.hide": "إخفاء المقطع الصوتي",
"block_modal.remote_users_caveat": "سوف نطلب من الخادم {domain} أن يحترم قرارك، لكن الالتزام غير مضمون لأن بعض الخواديم قد تتعامل مع نصوص الكتل بشكل مختلف. قد تظل المنشورات العامة مرئية للمستخدمين غير المسجلين الدخول.", "block_modal.remote_users_caveat": "سوف نطلب من الخادم {domain} أن يحترم قرارك، لكن الالتزام غير مضمون لأن بعض الخواديم قد تتعامل مع نصوص الكتل بشكل مختلف. قد تظل المنشورات العامة مرئية للمستخدمين غير المسجلين الدخول.",
@ -137,11 +161,21 @@
"bundle_modal_error.close": "إغلاق", "bundle_modal_error.close": "إغلاق",
"bundle_modal_error.message": "حدث خطأ أثناء تحميل هذه الشاشة.", "bundle_modal_error.message": "حدث خطأ أثناء تحميل هذه الشاشة.",
"bundle_modal_error.retry": "إعادة المُحاولة", "bundle_modal_error.retry": "إعادة المُحاولة",
"callout.dismiss": "تجاهل",
"closed_registrations.other_server_instructions": "بما أن ماستدون لامركزي، يمكنك إنشاء حساب على خادم آخر للاستمرار في التفاعل مع هذا الخادم.", "closed_registrations.other_server_instructions": "بما أن ماستدون لامركزي، يمكنك إنشاء حساب على خادم آخر للاستمرار في التفاعل مع هذا الخادم.",
"closed_registrations_modal.description": "لا يمكن إنشاء حساب على {domain} حاليا، ولكن على فكرة لست بحاجة إلى حساب على {domain} بذاته لاستخدام ماستدون.", "closed_registrations_modal.description": "لا يمكن إنشاء حساب على {domain} حاليا، ولكن على فكرة لست بحاجة إلى حساب على {domain} بذاته لاستخدام ماستدون.",
"closed_registrations_modal.find_another_server": "ابحث على خادم آخر", "closed_registrations_modal.find_another_server": "ابحث على خادم آخر",
"closed_registrations_modal.preamble": "ماستدون لامركزي، لذلك بغض النظر عن مكان إنشاء حسابك، سيكون بإمكانك المتابعة والتفاعل مع أي شخص على هذا الخادم. يمكنك حتى أن تستضيفه ذاتياً!", "closed_registrations_modal.preamble": "ماستدون لامركزي، لذلك بغض النظر عن مكان إنشاء حسابك، سيكون بإمكانك المتابعة والتفاعل مع أي شخص على هذا الخادم. يمكنك حتى أن تستضيفه ذاتياً!",
"closed_registrations_modal.title": "إنشاء حساب على ماستدون", "closed_registrations_modal.title": "إنشاء حساب على ماستدون",
"collections.collection_description": "الوصف",
"collections.collection_name": "الاسم",
"collections.collection_topic": "الموضوع",
"collections.content_warning": "تحذير عن المحتوى",
"collections.continue": "مواصلة",
"collections.create.settings_title": "الإعدادات",
"collections.create.steps": "الخطوة {step}/{total}",
"collections.edit_settings": "تعديل الإعدادات",
"collections.manage_accounts": "إدارة الحسابات",
"column.about": "عن", "column.about": "عن",
"column.blocks": "المُستَخدِمون المَحظورون", "column.blocks": "المُستَخدِمون المَحظورون",
"column.bookmarks": "الفواصل المرجعية", "column.bookmarks": "الفواصل المرجعية",
@ -201,6 +235,7 @@
"confirmations.delete.confirm": "حذف", "confirmations.delete.confirm": "حذف",
"confirmations.delete.message": "هل أنتَ مُتأكدٌ أنك تُريدُ حَذفَ هذا المنشور؟", "confirmations.delete.message": "هل أنتَ مُتأكدٌ أنك تُريدُ حَذفَ هذا المنشور؟",
"confirmations.delete.title": "أتريد حذف المنشور؟", "confirmations.delete.title": "أتريد حذف المنشور؟",
"confirmations.delete_collection.confirm": "حذف",
"confirmations.delete_list.confirm": "حذف", "confirmations.delete_list.confirm": "حذف",
"confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمة بشكلٍ دائم؟", "confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمة بشكلٍ دائم؟",
"confirmations.delete_list.title": "أتريد حذف القائمة؟", "confirmations.delete_list.title": "أتريد حذف القائمة؟",
@ -375,6 +410,9 @@
"follow_suggestions.who_to_follow": "حسابات للمُتابَعة", "follow_suggestions.who_to_follow": "حسابات للمُتابَعة",
"followed_tags": "الوسوم المتابَعة", "followed_tags": "الوسوم المتابَعة",
"footer.about": "عن", "footer.about": "عن",
"footer.about_mastodon": "عن ماستدون",
"footer.about_server": "عن {domain}",
"footer.about_this_server": "عن",
"footer.directory": "دليل الصفحات التعريفية", "footer.directory": "دليل الصفحات التعريفية",
"footer.get_app": "احصل على التطبيق", "footer.get_app": "احصل على التطبيق",
"footer.keyboard_shortcuts": "اختصارات لوحة المفاتيح", "footer.keyboard_shortcuts": "اختصارات لوحة المفاتيح",
@ -382,6 +420,7 @@
"footer.source_code": "الاطلاع على الشفرة المصدرية", "footer.source_code": "الاطلاع على الشفرة المصدرية",
"footer.status": "الحالة", "footer.status": "الحالة",
"footer.terms_of_service": "شروط الخدمة", "footer.terms_of_service": "شروط الخدمة",
"form_field.optional": "(اختياري)",
"generic.saved": "تم الحفظ", "generic.saved": "تم الحفظ",
"getting_started.heading": "استعدّ للبدء", "getting_started.heading": "استعدّ للبدء",
"hashtag.admin_moderation": "افتح الواجهة الإشراف لـ #{name}", "hashtag.admin_moderation": "افتح الواجهة الإشراف لـ #{name}",
@ -448,6 +487,7 @@
"keyboard_shortcuts.column": "للتركيز على منشور على أحد الأعمدة", "keyboard_shortcuts.column": "للتركيز على منشور على أحد الأعمدة",
"keyboard_shortcuts.compose": "للتركيز على نافذة تحرير النصوص", "keyboard_shortcuts.compose": "للتركيز على نافذة تحرير النصوص",
"keyboard_shortcuts.description": "الوصف", "keyboard_shortcuts.description": "الوصف",
"keyboard_shortcuts.direct": "لفتح عمود الإشارات الخاصة",
"keyboard_shortcuts.down": "للانتقال إلى أسفل القائمة", "keyboard_shortcuts.down": "للانتقال إلى أسفل القائمة",
"keyboard_shortcuts.enter": "لفتح المنشور", "keyboard_shortcuts.enter": "لفتح المنشور",
"keyboard_shortcuts.favourite": "لإضافة المنشور إلى المفضلة", "keyboard_shortcuts.favourite": "لإضافة المنشور إلى المفضلة",
@ -457,6 +497,7 @@
"keyboard_shortcuts.home": "لفتح الخيط الرئيسي", "keyboard_shortcuts.home": "لفتح الخيط الرئيسي",
"keyboard_shortcuts.hotkey": "مفتاح الاختصار", "keyboard_shortcuts.hotkey": "مفتاح الاختصار",
"keyboard_shortcuts.legend": "لعرض هذا المفتاح", "keyboard_shortcuts.legend": "لعرض هذا المفتاح",
"keyboard_shortcuts.load_more": "للتركيز على زر \"تحميل المزيد\"",
"keyboard_shortcuts.local": "لفتح الخيط العام المحلي", "keyboard_shortcuts.local": "لفتح الخيط العام المحلي",
"keyboard_shortcuts.mention": "لذِكر الناشر", "keyboard_shortcuts.mention": "لذِكر الناشر",
"keyboard_shortcuts.muted": "لفتح قائمة المستخدِمين المكتومين", "keyboard_shortcuts.muted": "لفتح قائمة المستخدِمين المكتومين",
@ -873,6 +914,7 @@
"status.quote_error.revoked": "تمت إزالة المنشور من قبل صاحبه", "status.quote_error.revoked": "تمت إزالة المنشور من قبل صاحبه",
"status.quote_followers_only": "يمكن فقط للمتابعين اقتباس هذا المنشور", "status.quote_followers_only": "يمكن فقط للمتابعين اقتباس هذا المنشور",
"status.quote_manual_review": "الكاتب سوف يراجع يدوياً", "status.quote_manual_review": "الكاتب سوف يراجع يدوياً",
"status.quote_noun": "اقتباس",
"status.quote_policy_change": "تغيير من يمكنه الاقتباس", "status.quote_policy_change": "تغيير من يمكنه الاقتباس",
"status.quote_post_author": "اقتبس @{name} منشورا", "status.quote_post_author": "اقتبس @{name} منشورا",
"status.quote_private": "المنشورات الخاصة لا يمكن اقتباسها", "status.quote_private": "المنشورات الخاصة لا يمكن اقتباسها",
@ -913,6 +955,7 @@
"tabs_bar.notifications": "الإشعارات", "tabs_bar.notifications": "الإشعارات",
"tabs_bar.publish": "منشور جديد", "tabs_bar.publish": "منشور جديد",
"tabs_bar.search": "ابحث", "tabs_bar.search": "ابحث",
"tag.remove": "إزالة",
"terms_of_service.effective_as_of": "مطبق اعتباراً من {date}", "terms_of_service.effective_as_of": "مطبق اعتباراً من {date}",
"terms_of_service.title": "شروط الخدمة", "terms_of_service.title": "شروط الخدمة",
"terms_of_service.upcoming_changes_on": "تغييرات قادمة في تاريخ {date}", "terms_of_service.upcoming_changes_on": "تغييرات قادمة في تاريخ {date}",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Схаваць пашырэнні ў стужцы", "account.menu.hide_reblogs": "Схаваць пашырэнні ў стужцы",
"account.menu.mention": "Згадаць", "account.menu.mention": "Згадаць",
"account.menu.mute": "Ігнараваць уліковы запіс", "account.menu.mute": "Ігнараваць уліковы запіс",
"account.menu.note.description": "Бачна толькі Вам",
"account.menu.open_original_page": "Паказаць на {domain}", "account.menu.open_original_page": "Паказаць на {domain}",
"account.menu.remove_follower": "Выдаліць падпісчыка", "account.menu.remove_follower": "Выдаліць падпісчыка",
"account.menu.report": "Паскардзіцца на профіль", "account.menu.report": "Паскардзіцца на профіль",
@ -104,6 +105,13 @@
"account.muted": "Ігнаруецца", "account.muted": "Ігнаруецца",
"account.muting": "Ігнараванне", "account.muting": "Ігнараванне",
"account.mutual": "Вы падпісаныя адно на аднаго", "account.mutual": "Вы падпісаныя адно на аднаго",
"account.name.help.domain": "{domain} — сервер, які ўтрымлівае профіль і допісы гэтага карыстальніка.",
"account.name.help.domain_self": "{domain} — Ваш сервер, які ўтрымлівае Ваш профіль і допісы.",
"account.name.help.footer": "Гэтак жа, як Вы можаце адпраўляць электронныя лісты людзям праз розныя кліенты электроннай пошты, Вы таксама можаце ўзаемадзейнічаць з людзьмі з іншых сервераў Mastodon, а таксама з усімі, хто карыстаецца іншымі сацыяльнымі сеткамі, якія працуюць па тых жа правілах, што і Mastodon (пратакол ActivityPub).",
"account.name.help.header": "Ідэнтыфікатар карыстальніка падобны да адраса электроннай пошты",
"account.name.help.username": "{username} — імя карыстальніка гэтага ўліковага запісу на гэтым серверы. У некага на іншым серверы яно можа быць такім жа.",
"account.name.help.username_self": "{username} — Вашае імя карыстальніка на гэтым серверы. У некага на іншым серверы яно можа быць такім жа.",
"account.name_info": "Што гэта азначае?",
"account.no_bio": "Апісанне адсутнічае.", "account.no_bio": "Апісанне адсутнічае.",
"account.node_modal.callout": "Асабістыя нататкі бачныя толькі Вам.", "account.node_modal.callout": "Асабістыя нататкі бачныя толькі Вам.",
"account.node_modal.edit_title": "Рэдагаваць асабістую нататку", "account.node_modal.edit_title": "Рэдагаваць асабістую нататку",
@ -312,7 +320,7 @@
"compose.published.open": "Адкрыць", "compose.published.open": "Адкрыць",
"compose.saved.body": "Допіс захаваны.", "compose.saved.body": "Допіс захаваны.",
"compose_form.direct_message_warning_learn_more": "Даведацца больш", "compose_form.direct_message_warning_learn_more": "Даведацца больш",
"compose_form.encryption_warning": "Допісы ў Mastodon не абаронены скразным шыфраваннем. Не дзяліцеся ніякай канфідэнцыяльнай інфармацыяй праз Mastodon.", "compose_form.encryption_warning": "Допісы ў Mastodon не абароненыя скразным шыфраваннем. Не дзяліцеся ніякай канфідэнцыяльнай інфармацыяй праз Mastodon.",
"compose_form.hashtag_warning": "Гэты допіс не будзе паказаны пад аніякім хэштэгам, бо ён не публічны. Толькі публічныя допісы можна знайсці па хэштэгу.", "compose_form.hashtag_warning": "Гэты допіс не будзе паказаны пад аніякім хэштэгам, бо ён не публічны. Толькі публічныя допісы можна знайсці па хэштэгу.",
"compose_form.lock_disclaimer": "Ваш уліковы запіс не {locked}. Усе могуць падпісацца на вас, каб бачыць допісы толькі для падпісчыкаў.", "compose_form.lock_disclaimer": "Ваш уліковы запіс не {locked}. Усе могуць падпісацца на вас, каб бачыць допісы толькі для падпісчыкаў.",
"compose_form.lock_disclaimer.lock": "закрыты", "compose_form.lock_disclaimer.lock": "закрыты",
@ -526,7 +534,7 @@
"follow_suggestions.similar_to_recently_followed_longer": "Падобныя профілі, за якімі Вы нядаўна сачылі", "follow_suggestions.similar_to_recently_followed_longer": "Падобныя профілі, за якімі Вы нядаўна сачылі",
"follow_suggestions.view_all": "Праглядзець усё", "follow_suggestions.view_all": "Праглядзець усё",
"follow_suggestions.who_to_follow": "На каго падпісацца", "follow_suggestions.who_to_follow": "На каго падпісацца",
"followed_tags": "Падпіскі", "followed_tags": "Падпіскі на хэштэгі",
"footer.about": "Пра нас", "footer.about": "Пра нас",
"footer.about_mastodon": "Пра Mastodon", "footer.about_mastodon": "Пра Mastodon",
"footer.about_server": "Пра {domain}", "footer.about_server": "Пра {domain}",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Skjul fremhævelser på tidslinje", "account.menu.hide_reblogs": "Skjul fremhævelser på tidslinje",
"account.menu.mention": "Omtal", "account.menu.mention": "Omtal",
"account.menu.mute": "Skjul konto", "account.menu.mute": "Skjul konto",
"account.menu.note.description": "Kun synlig for dig",
"account.menu.open_original_page": "Vis på {domain}", "account.menu.open_original_page": "Vis på {domain}",
"account.menu.remove_follower": "Fjern følger", "account.menu.remove_follower": "Fjern følger",
"account.menu.report": "Anmeld konto", "account.menu.report": "Anmeld konto",
@ -104,6 +105,13 @@
"account.muted": "Skjult", "account.muted": "Skjult",
"account.muting": "Skjuler", "account.muting": "Skjuler",
"account.mutual": "I følger hinanden", "account.mutual": "I følger hinanden",
"account.name.help.domain": "{domain} er den server, der er vært for brugerens profil og indlæg.",
"account.name.help.domain_self": "{domain} er din server, der er vært for din profil og indlæg.",
"account.name.help.footer": "Ligesom du kan sende e-mails til personer, der bruger forskellige e-mail-klienter, kan du interagere med personer på andre Mastodon-servere og med alle på andre sociale apps, der bruger de samme regler som Mastodon (ActivityPub-protokollen).",
"account.name.help.header": "Et handle svarer til en e-mailadresse",
"account.name.help.username": "{username} er denne kontos brugernavn på deres server. En konto på en anden server kan have det samme brugernavn.",
"account.name.help.username_self": "{username} er dit brugernavn på denne server. En konto på en anden server kan have det samme brugernavn.",
"account.name_info": "Hvad betyder dette?",
"account.no_bio": "Ingen beskrivelse til rådighed.", "account.no_bio": "Ingen beskrivelse til rådighed.",
"account.node_modal.callout": "Personlige noter er kun synlige for dig.", "account.node_modal.callout": "Personlige noter er kun synlige for dig.",
"account.node_modal.edit_title": "Rediger personlig note", "account.node_modal.edit_title": "Rediger personlig note",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Geteilte Beiträge in der Timeline ausblenden", "account.menu.hide_reblogs": "Geteilte Beiträge in der Timeline ausblenden",
"account.menu.mention": "Erwähnen", "account.menu.mention": "Erwähnen",
"account.menu.mute": "Konto stummschalten", "account.menu.mute": "Konto stummschalten",
"account.menu.note.description": "Nur für dich sichtbar",
"account.menu.open_original_page": "Auf {domain} ansehen", "account.menu.open_original_page": "Auf {domain} ansehen",
"account.menu.remove_follower": "Follower entfernen", "account.menu.remove_follower": "Follower entfernen",
"account.menu.report": "Konto melden", "account.menu.report": "Konto melden",
@ -104,6 +105,13 @@
"account.muted": "Stummgeschaltet", "account.muted": "Stummgeschaltet",
"account.muting": "Stummgeschaltet", "account.muting": "Stummgeschaltet",
"account.mutual": "Ihr folgt einander", "account.mutual": "Ihr folgt einander",
"account.name.help.domain": "{domain} ist der Server, auf dem das Profil registriert ist und die Beiträge verwaltet werden.",
"account.name.help.domain_self": "{domain} ist der Server, auf dem du registriert bist und deine Beiträge verwaltet werden.",
"account.name.help.footer": "So wie du E-Mails an andere trotz unterschiedlicher E-Mail-Clients senden kannst, so kannst du auch mit anderen Profilen auf unterschiedlichen Mastodon-Servern interagieren. Wenn andere soziale Apps die gleichen Kommunikationsregeln (das ActivityPub-Protokoll) wie Mastodon verwenden, dann funktioniert die Kommunikation auch dort.",
"account.name.help.header": "Deine Adresse im Fediverse ist wie eine E-Mail-Adresse",
"account.name.help.username": "{username} ist der Profilname auf deren Server. Es ist möglich, dass jemand auf einem anderen Server den gleichen Profilnamen hat.",
"account.name.help.username_self": "{username} ist dein Profilname auf diesem Server. Es ist möglich, dass jemand auf einem anderen Server den gleichen Profilnamen hat.",
"account.name_info": "Was bedeutet das?",
"account.no_bio": "Keine Beschreibung verfügbar.", "account.no_bio": "Keine Beschreibung verfügbar.",
"account.node_modal.callout": "Persönliche Notizen sind nur für dich sichtbar.", "account.node_modal.callout": "Persönliche Notizen sind nur für dich sichtbar.",
"account.node_modal.edit_title": "Persönliche Notiz bearbeiten", "account.node_modal.edit_title": "Persönliche Notiz bearbeiten",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Απόκρυψη ενισχύσεων στο χρονολόγιο", "account.menu.hide_reblogs": "Απόκρυψη ενισχύσεων στο χρονολόγιο",
"account.menu.mention": "Επισήμανση", "account.menu.mention": "Επισήμανση",
"account.menu.mute": "Σίγαση λογαριασμού", "account.menu.mute": "Σίγαση λογαριασμού",
"account.menu.note.description": "Ορατή μόνο σε εσάς",
"account.menu.open_original_page": "Προβολή στο {domain}", "account.menu.open_original_page": "Προβολή στο {domain}",
"account.menu.remove_follower": "Αφαίρεση ακολούθου", "account.menu.remove_follower": "Αφαίρεση ακολούθου",
"account.menu.report": "Αναφορά λογαριασμού", "account.menu.report": "Αναφορά λογαριασμού",
@ -104,6 +105,13 @@
"account.muted": "Σε σίγαση", "account.muted": "Σε σίγαση",
"account.muting": "Σίγαση", "account.muting": "Σίγαση",
"account.mutual": "Ακολουθείτε ο ένας τον άλλο", "account.mutual": "Ακολουθείτε ο ένας τον άλλο",
"account.name.help.domain": "Το {domain} είναι ο διακομιστής που φιλοξενεί το προφίλ και τις αναρτήσεις του χρήστη.",
"account.name.help.domain_self": "Το {domain} είναι ο διακομιστής σας που φιλοξενεί το προφίλ και τις αναρτήσεις σας.",
"account.name.help.footer": "Ακριβώς όπως μπορείτε να στείλετε μηνύματα ηλεκτρονικού ταχυδρομείου σε άτομα χρησιμοποιώντας διαφορετικούς παρόχους email, μπορείτε να αλληλεπιδράσετε με άτομα σε άλλους διακομιστές Mastodon και με οποιονδήποτε σε άλλες κοινωνικές εφαρμογές που τροφοδοτούνται από το ίδιο σύνολο κανόνων όπως χρησιμοποιεί το Mastodon (το πρωτόκολλο ActivityPub).",
"account.name.help.header": "Ένα πλήρες όνομα χρήστη είναι σαν μια διεύθυνση email",
"account.name.help.username": "Το {username} είναι το όνομα χρήστη αυτού του λογαριασμού στο διακομιστή τους. Κάποιος σε άλλο διακομιστή μπορεί να έχει το ίδιο όνομα χρήστη.",
"account.name.help.username_self": "Το {username} είναι το όνομα χρήστη σας σε αυτόν το διακομιστή. Κάποιος σε άλλο διακομιστή μπορεί να έχει το ίδιο όνομα χρήστη.",
"account.name_info": "Τι σημαίνει αυτό;",
"account.no_bio": "Δεν υπάρχει περιγραφή.", "account.no_bio": "Δεν υπάρχει περιγραφή.",
"account.node_modal.callout": "Οι προσωπικές σημειώσεις είναι ορατές μόνο σε εσάς.", "account.node_modal.callout": "Οι προσωπικές σημειώσεις είναι ορατές μόνο σε εσάς.",
"account.node_modal.edit_title": "Επεξεργασία προσωπικής σημείωσης", "account.node_modal.edit_title": "Επεξεργασία προσωπικής σημείωσης",
@ -421,7 +429,7 @@
"domain_pill.their_username": "Το μοναδικό του αναγνωριστικό στο διακομιστή του. Είναι πιθανό να βρεις χρήστες με το ίδιο όνομα χρήστη σε διαφορετικούς διακομιστές.", "domain_pill.their_username": "Το μοναδικό του αναγνωριστικό στο διακομιστή του. Είναι πιθανό να βρεις χρήστες με το ίδιο όνομα χρήστη σε διαφορετικούς διακομιστές.",
"domain_pill.username": "Όνομα χρήστη", "domain_pill.username": "Όνομα χρήστη",
"domain_pill.whats_in_a_handle": "Τί υπάρχει σε ένα πλήρες όνομα χρήστη;", "domain_pill.whats_in_a_handle": "Τί υπάρχει σε ένα πλήρες όνομα χρήστη;",
"domain_pill.who_they_are": "Από τη στιγμή που τα πλήρη ονόματα λένε ποιος είναι κάποιος και πού είναι, μπορείς να αλληλεπιδράσεις με άτομα απ' όλο τον κοινωνικό ιστό των <button> πλατφορμών που στηρίζονται στο ActivityPub</button>.", "domain_pill.who_they_are": "Από τη στιγμή που τα πλήρη ονόματα χρηστών λένε ποιος είναι κάποιος και πού είναι, μπορείς να αλληλεπιδράσεις με άτομα απ' όλο τον κοινωνικό ιστό των <button>πλατφορμών που στηρίζονται στο ActivityPub</button>.",
"domain_pill.who_you_are": "Επειδή το πλήρες όνομα χρήστη σου λέει ποιος είσαι και πού βρίσκεσαι, άτομα μπορούν να αλληλεπιδράσουν μαζί σου στον κοινωνικό ιστό των <button>πλατφορμών που στηρίζονται στο ActivityPub</button>.", "domain_pill.who_you_are": "Επειδή το πλήρες όνομα χρήστη σου λέει ποιος είσαι και πού βρίσκεσαι, άτομα μπορούν να αλληλεπιδράσουν μαζί σου στον κοινωνικό ιστό των <button>πλατφορμών που στηρίζονται στο ActivityPub</button>.",
"domain_pill.your_handle": "Το πλήρες όνομα χρήστη σου:", "domain_pill.your_handle": "Το πλήρες όνομα χρήστη σου:",
"domain_pill.your_server": "Το ψηφιακό σου σπίτι, όπου ζουν όλες σου οι αναρτήσεις. Δε σ' αρέσει αυτός; Μετακινήσου σε διακομιστές ανά πάσα στιγμή και πάρε και τους ακόλουθούς σου.", "domain_pill.your_server": "Το ψηφιακό σου σπίτι, όπου ζουν όλες σου οι αναρτήσεις. Δε σ' αρέσει αυτός; Μετακινήσου σε διακομιστές ανά πάσα στιγμή και πάρε και τους ακόλουθούς σου.",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Hide boosts in timeline", "account.menu.hide_reblogs": "Hide boosts in timeline",
"account.menu.mention": "Mention", "account.menu.mention": "Mention",
"account.menu.mute": "Mute account", "account.menu.mute": "Mute account",
"account.menu.note.description": "Visible only to you",
"account.menu.open_original_page": "View on {domain}", "account.menu.open_original_page": "View on {domain}",
"account.menu.remove_follower": "Remove follower", "account.menu.remove_follower": "Remove follower",
"account.menu.report": "Report account", "account.menu.report": "Report account",
@ -104,6 +105,13 @@
"account.muted": "Muted", "account.muted": "Muted",
"account.muting": "Muting", "account.muting": "Muting",
"account.mutual": "You follow each other", "account.mutual": "You follow each other",
"account.name.help.domain": "{domain} is the server that hosts the users profile and posts.",
"account.name.help.domain_self": "{domain} is your server that hosts your profile and posts.",
"account.name.help.footer": "Just like you can send emails to people using different email clients, you can interact with people on other Mastodon servers and with anyone on other social apps powered by the same set of rules as Mastodon uses (the ActivityPub protocol).",
"account.name.help.header": "A handle is like an email address",
"account.name.help.username": "{username} is this accounts username on their server. Someone on another server might have the same username.",
"account.name.help.username_self": "{username} is your username on this server. Someone on another server might have the same username.",
"account.name_info": "What does this mean?",
"account.no_bio": "No description provided.", "account.no_bio": "No description provided.",
"account.node_modal.callout": "Personal notes are visible only to you.", "account.node_modal.callout": "Personal notes are visible only to you.",
"account.node_modal.edit_title": "Edit personal note", "account.node_modal.edit_title": "Edit personal note",

View File

@ -535,6 +535,8 @@
"follow_suggestions.view_all": "View all", "follow_suggestions.view_all": "View all",
"follow_suggestions.who_to_follow": "Who to follow", "follow_suggestions.who_to_follow": "Who to follow",
"followed_tags": "Followed hashtags", "followed_tags": "Followed hashtags",
"followers.hide_other_followers": "This user has chosen to not make their other followers visible",
"following.hide_other_following": "This user has chosen to not make the rest of who they follow visible",
"footer.about": "About", "footer.about": "About",
"footer.about_mastodon": "About Mastodon", "footer.about_mastodon": "About Mastodon",
"footer.about_server": "About {domain}", "footer.about_server": "About {domain}",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Ocultar adhesiones en la línea temporal", "account.menu.hide_reblogs": "Ocultar adhesiones en la línea temporal",
"account.menu.mention": "Mención", "account.menu.mention": "Mención",
"account.menu.mute": "Silenciar cuenta", "account.menu.mute": "Silenciar cuenta",
"account.menu.note.description": "Visible solo para vos",
"account.menu.open_original_page": "Ver en {domain}", "account.menu.open_original_page": "Ver en {domain}",
"account.menu.remove_follower": "Quitar seguidor", "account.menu.remove_follower": "Quitar seguidor",
"account.menu.report": "Denunciar cuenta", "account.menu.report": "Denunciar cuenta",
@ -104,6 +105,13 @@
"account.muted": "Silenciado", "account.muted": "Silenciado",
"account.muting": "Silenciada", "account.muting": "Silenciada",
"account.mutual": "Se siguen mutuamente", "account.mutual": "Se siguen mutuamente",
"account.name.help.domain": "«{domain}» es el servidor que hospeda el perfil y los mensajes del usuario.",
"account.name.help.domain_self": "«{domain}» es tu servidor que hospeda tu perfil y tus mensajes.",
"account.name.help.footer": "Así como podés enviar correos electrónicos a personas usando diferentes clientes de email, del mismo modo podés interactuar con cuentas en otros servidores de Mastodon; y con cuentas en otras aplicaciones sociales que funcionen bajo las mismas normas que Mastodon (esto es, el protocolo ActivityPub).",
"account.name.help.header": "Un alias es similar a una dirección de correo electrónico",
"account.name.help.username": "«{username}» es el nombre de usuario de esta cuenta en su servidor. Alguien en otro servidor puede tener el mismo nombre de usuario.",
"account.name.help.username_self": "«{username}» es tu nombre de usuario en este servidor. Alguien en otro servidor puede tener el mismo nombre de usuario.",
"account.name_info": "¿Qué significa?",
"account.no_bio": "Sin descripción provista.", "account.no_bio": "Sin descripción provista.",
"account.node_modal.callout": "Las notas personales son visibles solo para vos.", "account.node_modal.callout": "Las notas personales son visibles solo para vos.",
"account.node_modal.edit_title": "Editar nota personal", "account.node_modal.edit_title": "Editar nota personal",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Ocultar impulsos en la cronología", "account.menu.hide_reblogs": "Ocultar impulsos en la cronología",
"account.menu.mention": "Mencionar", "account.menu.mention": "Mencionar",
"account.menu.mute": "Silenciar cuenta", "account.menu.mute": "Silenciar cuenta",
"account.menu.note.description": "Visible solo para ti",
"account.menu.open_original_page": "Ver en {domain}", "account.menu.open_original_page": "Ver en {domain}",
"account.menu.remove_follower": "Eliminar seguidor", "account.menu.remove_follower": "Eliminar seguidor",
"account.menu.report": "Denunciar cuenta", "account.menu.report": "Denunciar cuenta",
@ -104,6 +105,13 @@
"account.muted": "Silenciado", "account.muted": "Silenciado",
"account.muting": "Silenciando", "account.muting": "Silenciando",
"account.mutual": "Se siguen el uno al otro", "account.mutual": "Se siguen el uno al otro",
"account.name.help.domain": "{domain} es el servidor que aloja el perfil y las publicaciones del usuario.",
"account.name.help.domain_self": "{domain} es el servidor que aloja tu perfil y tus publicaciones.",
"account.name.help.footer": "Al igual que puedes enviar correos electrónicos a personas que utilizan diferentes clientes de correo electrónico, puedes interactuar con personas en otros servidores Mastodon, y con cualquier persona en otras aplicaciones sociales que funcionen con el mismo conjunto de reglas que utiliza Mastodon (el protocolo ActivityPub).",
"account.name.help.header": "Un identificador es como una dirección de correo electrónico",
"account.name.help.username": "{username} es el nombre de usuario de esta cuenta en su servidor. Es posible que alguien en otro servidor tenga el mismo nombre de usuario.",
"account.name.help.username_self": "{username} es tu nombre de usuario en este servidor. Es posible que alguien en otro servidor tenga el mismo nombre de usuario.",
"account.name_info": "¿Qué significa esto?",
"account.no_bio": "Sin biografía.", "account.no_bio": "Sin biografía.",
"account.node_modal.callout": "Las notas personales solo son visibles para ti.", "account.node_modal.callout": "Las notas personales solo son visibles para ti.",
"account.node_modal.edit_title": "Editar nota personal", "account.node_modal.edit_title": "Editar nota personal",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Ocultar impulsos en la cronología", "account.menu.hide_reblogs": "Ocultar impulsos en la cronología",
"account.menu.mention": "Mencionar", "account.menu.mention": "Mencionar",
"account.menu.mute": "Silenciar cuenta", "account.menu.mute": "Silenciar cuenta",
"account.menu.note.description": "Visible sólo para ti",
"account.menu.open_original_page": "Ver en {domain}", "account.menu.open_original_page": "Ver en {domain}",
"account.menu.remove_follower": "Eliminar seguidor", "account.menu.remove_follower": "Eliminar seguidor",
"account.menu.report": "Denunciar cuenta", "account.menu.report": "Denunciar cuenta",
@ -104,6 +105,13 @@
"account.muted": "Silenciado", "account.muted": "Silenciado",
"account.muting": "Silenciando", "account.muting": "Silenciando",
"account.mutual": "Os seguís mutuamente", "account.mutual": "Os seguís mutuamente",
"account.name.help.domain": "{domain} es el servidor que alberga el perfil y las publicaciones del usuario.",
"account.name.help.domain_self": "{domain} es tu servidor que aloja tu perfil y publicaciones.",
"account.name.help.footer": "Al igual que puedes enviar correos electrónicos a personas usando diferentes clientes de correo electrónico, puedes interactuar con personas en otros servidores de Mastodon y con personas en otras aplicaciones sociales que hablen el mismo idioma que Mastodon (el protocolo ActivityPub).",
"account.name.help.header": "Un alias es como una dirección de correo electrónico",
"account.name.help.username": "{username} es el nombre de usuario de esta cuenta en su servidor. Alguien en otro servidor puede tener el mismo nombre de usuario.",
"account.name.help.username_self": "{username} es tu nombre de usuario en este servidor. Alguien en otro servidor puede tener el mismo nombre de usuario.",
"account.name_info": "¿Qué significa esto?",
"account.no_bio": "Sin biografía.", "account.no_bio": "Sin biografía.",
"account.node_modal.callout": "Las notas personales solo son visibles para ti.", "account.node_modal.callout": "Las notas personales solo son visibles para ti.",
"account.node_modal.edit_title": "Editar nota personal", "account.node_modal.edit_title": "Editar nota personal",
@ -242,7 +250,7 @@
"collections.content_warning": "Advertencia de contenido", "collections.content_warning": "Advertencia de contenido",
"collections.continue": "Continuar", "collections.continue": "Continuar",
"collections.create.accounts_subtitle": "Solo pueden añadirse cuentas que sigues y que han activado el descubrimiento.", "collections.create.accounts_subtitle": "Solo pueden añadirse cuentas que sigues y que han activado el descubrimiento.",
"collections.create.accounts_title": "¿A quién presentarás en esta colección?", "collections.create.accounts_title": "¿A quién vas a destacar en esta colección?",
"collections.create.basic_details_title": "Datos básicos", "collections.create.basic_details_title": "Datos básicos",
"collections.create.settings_title": "Ajustes", "collections.create.settings_title": "Ajustes",
"collections.create.steps": "Paso {step}/{total}", "collections.create.steps": "Paso {step}/{total}",
@ -250,7 +258,7 @@
"collections.create_collection": "Crear colección", "collections.create_collection": "Crear colección",
"collections.delete_collection": "Eliminar colección", "collections.delete_collection": "Eliminar colección",
"collections.description_length_hint": "Limitado a 100 caracteres", "collections.description_length_hint": "Limitado a 100 caracteres",
"collections.edit_details": "Cambiar datos básicos", "collections.edit_details": "Editar datos básicos",
"collections.edit_settings": "Cambiar ajustes", "collections.edit_settings": "Cambiar ajustes",
"collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.", "collections.error_loading_collections": "Se ha producido un error al intentar cargar tus colecciones.",
"collections.manage_accounts": "Administrar cuentas", "collections.manage_accounts": "Administrar cuentas",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Piilota tehostukset aikajanalta", "account.menu.hide_reblogs": "Piilota tehostukset aikajanalta",
"account.menu.mention": "Mainitse", "account.menu.mention": "Mainitse",
"account.menu.mute": "Mykistä tili", "account.menu.mute": "Mykistä tili",
"account.menu.note.description": "Näkyvät vain sinulle",
"account.menu.open_original_page": "Näytä palvelimella {domain}", "account.menu.open_original_page": "Näytä palvelimella {domain}",
"account.menu.remove_follower": "Poista seuraaja", "account.menu.remove_follower": "Poista seuraaja",
"account.menu.report": "Raportoi tili", "account.menu.report": "Raportoi tili",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Fjal stimbranir í tíðarlinju", "account.menu.hide_reblogs": "Fjal stimbranir í tíðarlinju",
"account.menu.mention": "Umrøð", "account.menu.mention": "Umrøð",
"account.menu.mute": "Doyv kontu", "account.menu.mute": "Doyv kontu",
"account.menu.note.description": "Einans sjónligt fyri teg",
"account.menu.open_original_page": "Vís á {domain}", "account.menu.open_original_page": "Vís á {domain}",
"account.menu.remove_follower": "Strika fylgjara", "account.menu.remove_follower": "Strika fylgjara",
"account.menu.report": "Melda kontu", "account.menu.report": "Melda kontu",
@ -104,6 +105,13 @@
"account.muted": "Sløkt/ur", "account.muted": "Sløkt/ur",
"account.muting": "Doyvir", "account.muting": "Doyvir",
"account.mutual": "Tit fylgja hvønn annan", "account.mutual": "Tit fylgja hvønn annan",
"account.name.help.domain": "{domain} er ambætarin, sum hýsir vangan og postarnar hjá brúkaranum.",
"account.name.help.domain_self": "{domain} er tín ambætari, sum hýsir tín vanga og postar.",
"account.name.help.footer": "Eins og tú kanst senda teldubrøv til fólk, sum brúka ymiskar teldupostskipanir, so kanst tú samvirka við fólk á øðrum Mastodon ambætarum - og við ein og hvønn á øðrum sosialum appum, ið eru bygdar á sama sett av reglum, sum Mastodon brúkar (ActivityPub protokollin).",
"account.name.help.header": "Eitt hald er sum ein teldupostbústaður",
"account.name.help.username": "{username} er brúkaranavnið hjá hesari kontuni á teirra ambætara. Onkur á einum øðrum ambætara kann hava sama brúkaranavn.",
"account.name.help.username_self": "{username} er brúkaranavnið hjá tær á hesum ambætaranum. Onkur á einum øðrum ambætara kann hava sama brúkaranavn.",
"account.name_info": "Hvat merkir hetta?",
"account.no_bio": "Lýsing vantar.", "account.no_bio": "Lýsing vantar.",
"account.node_modal.callout": "Einans tú sær tínar persónligu notur.", "account.node_modal.callout": "Einans tú sær tínar persónligu notur.",
"account.node_modal.edit_title": "Rætta persónliga notu", "account.node_modal.edit_title": "Rætta persónliga notu",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Masquer les partages dans le fil dactualité", "account.menu.hide_reblogs": "Masquer les partages dans le fil dactualité",
"account.menu.mention": "Mentionner", "account.menu.mention": "Mentionner",
"account.menu.mute": "Masquer le compte", "account.menu.mute": "Masquer le compte",
"account.menu.note.description": "Visible uniquement par vous",
"account.menu.open_original_page": "Voir sur {domain}", "account.menu.open_original_page": "Voir sur {domain}",
"account.menu.remove_follower": "Supprimer des abonné·e·s", "account.menu.remove_follower": "Supprimer des abonné·e·s",
"account.menu.report": "Signaler le compte", "account.menu.report": "Signaler le compte",
@ -104,6 +105,13 @@
"account.muted": "Masqué·e", "account.muted": "Masqué·e",
"account.muting": "Masqué", "account.muting": "Masqué",
"account.mutual": "Vous vous suivez mutuellement", "account.mutual": "Vous vous suivez mutuellement",
"account.name.help.domain": "{domain} est le serveur qui héberge le profil et les messages du compte.",
"account.name.help.domain_self": "{domain} est le serveur qui héberge votre profil et vos messages.",
"account.name.help.footer": "Tout comme vous pouvez envoyer des courriels à des personnes utilisant différents logiciels de messagerie, vous pouvez interagir avec des personnes sur d'autres serveurs Mastodon — et avec n'importe qui sur d'autres applications sociales propulsées par le même ensemble de règles que Mastodon utilise (le protocole ActivityPub).",
"account.name.help.header": "Un identifiant est comme une adresse de courriel",
"account.name.help.username": "{username} est le nom d'utilisateur·ice de ce compte sur son serveur. Quelqu'un sur un autre serveur peut avoir le même nom.",
"account.name.help.username_self": "{username} est votre nom d'utilisateur·ice sur ce serveur. Quelqu'un sur un autre serveur peut avoir le même nom.",
"account.name_info": "Qu'est-ce que cela signifie ?",
"account.no_bio": "Description manquante.", "account.no_bio": "Description manquante.",
"account.node_modal.callout": "Les notes personnelles sont ne sont visibles que pour vous.", "account.node_modal.callout": "Les notes personnelles sont ne sont visibles que pour vous.",
"account.node_modal.edit_title": "Modifier la note personnelle", "account.node_modal.edit_title": "Modifier la note personnelle",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Masquer les partages dans le fil dactualité", "account.menu.hide_reblogs": "Masquer les partages dans le fil dactualité",
"account.menu.mention": "Mentionner", "account.menu.mention": "Mentionner",
"account.menu.mute": "Masquer le compte", "account.menu.mute": "Masquer le compte",
"account.menu.note.description": "Visible uniquement par vous",
"account.menu.open_original_page": "Voir sur {domain}", "account.menu.open_original_page": "Voir sur {domain}",
"account.menu.remove_follower": "Supprimer des abonné·e·s", "account.menu.remove_follower": "Supprimer des abonné·e·s",
"account.menu.report": "Signaler le compte", "account.menu.report": "Signaler le compte",
@ -104,6 +105,13 @@
"account.muted": "Masqué·e", "account.muted": "Masqué·e",
"account.muting": "Masqué", "account.muting": "Masqué",
"account.mutual": "Vous vous suivez mutuellement", "account.mutual": "Vous vous suivez mutuellement",
"account.name.help.domain": "{domain} est le serveur qui héberge le profil et les messages du compte.",
"account.name.help.domain_self": "{domain} est le serveur qui héberge votre profil et vos messages.",
"account.name.help.footer": "Tout comme vous pouvez envoyer des courriels à des personnes utilisant différents logiciels de messagerie, vous pouvez interagir avec des personnes sur d'autres serveurs Mastodon — et avec n'importe qui sur d'autres applications sociales propulsées par le même ensemble de règles que Mastodon utilise (le protocole ActivityPub).",
"account.name.help.header": "Un identifiant est comme une adresse de courriel",
"account.name.help.username": "{username} est le nom d'utilisateur·ice de ce compte sur son serveur. Quelqu'un sur un autre serveur peut avoir le même nom.",
"account.name.help.username_self": "{username} est votre nom d'utilisateur·ice sur ce serveur. Quelqu'un sur un autre serveur peut avoir le même nom.",
"account.name_info": "Qu'est-ce que cela signifie ?",
"account.no_bio": "Aucune description fournie.", "account.no_bio": "Aucune description fournie.",
"account.node_modal.callout": "Les notes personnelles sont ne sont visibles que pour vous.", "account.node_modal.callout": "Les notes personnelles sont ne sont visibles que pour vous.",
"account.node_modal.edit_title": "Modifier la note personnelle", "account.node_modal.edit_title": "Modifier la note personnelle",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Folaigh borradh sa líne ama", "account.menu.hide_reblogs": "Folaigh borradh sa líne ama",
"account.menu.mention": "Luaigh", "account.menu.mention": "Luaigh",
"account.menu.mute": "Balbhaigh cuntas", "account.menu.mute": "Balbhaigh cuntas",
"account.menu.note.description": "Le feiceáil agatsa amháin",
"account.menu.open_original_page": "Féach ar {domain}", "account.menu.open_original_page": "Féach ar {domain}",
"account.menu.remove_follower": "Bain leantóir", "account.menu.remove_follower": "Bain leantóir",
"account.menu.report": "Tuairiscigh cuntas", "account.menu.report": "Tuairiscigh cuntas",
@ -104,6 +105,13 @@
"account.muted": "Balbhaithe", "account.muted": "Balbhaithe",
"account.muting": "Ag balbhaigh", "account.muting": "Ag balbhaigh",
"account.mutual": "Leanann sibh a chéile", "account.mutual": "Leanann sibh a chéile",
"account.name.help.domain": "Is é {domain} an freastalaí a óstálann próifíl agus poist an úsáideora.",
"account.name.help.domain_self": "Is é {domain} an freastalaí a óstálann do phróifíl agus do phoist.",
"account.name.help.footer": "Díreach mar is féidir leat ríomhphoist a sheoladh chuig daoine ag baint úsáide as cliaint ríomhphoist éagsúla, is féidir leat idirghníomhú le daoine ar fhreastalaithe Mastodon eile agus le duine ar bith ar aipeanna sóisialta eile atá faoi thiomáint ag an tsraith rialacha chéanna a úsáideann Mastodon (an prótacal ActivityPub).",
"account.name.help.header": "Is cosúil le seoladh ríomhphoist é láimhseáil",
"account.name.help.username": "Is é {username} ainm úsáideora an chuntais seo ar a bhfreastalaí. Dfhéadfadh an t-ainm úsáideora céanna a bheith ag duine éigin ar fhreastalaí eile.",
"account.name.help.username_self": "Is é {username} d'ainm úsáideora ar an bhfreastalaí seo. D'fhéadfadh an t-ainm úsáideora céanna a bheith ag duine éigin ar fhreastalaí eile.",
"account.name_info": "Cad is brí leis seo?",
"account.no_bio": "Níor tugadh tuairisc.", "account.no_bio": "Níor tugadh tuairisc.",
"account.node_modal.callout": "Ní féidir ach leatsa nótaí pearsanta a fheiceáil.", "account.node_modal.callout": "Ní féidir ach leatsa nótaí pearsanta a fheiceáil.",
"account.node_modal.edit_title": "Cuir nóta pearsanta in eagar", "account.node_modal.edit_title": "Cuir nóta pearsanta in eagar",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Ocultar as promocións na cronoloxía", "account.menu.hide_reblogs": "Ocultar as promocións na cronoloxía",
"account.menu.mention": "Mencionar a", "account.menu.mention": "Mencionar a",
"account.menu.mute": "Silenciar conta", "account.menu.mute": "Silenciar conta",
"account.menu.note.description": "Só ti podes vela",
"account.menu.open_original_page": "Ver en {domain}", "account.menu.open_original_page": "Ver en {domain}",
"account.menu.remove_follower": "Quitar seguidora", "account.menu.remove_follower": "Quitar seguidora",
"account.menu.report": "Denunciar conta", "account.menu.report": "Denunciar conta",
@ -104,6 +105,13 @@
"account.muted": "Acalada", "account.muted": "Acalada",
"account.muting": "Silenciamento", "account.muting": "Silenciamento",
"account.mutual": "Seguimento mútuo", "account.mutual": "Seguimento mútuo",
"account.name.help.domain": "{domain} é o servidor que aloxa o perfil e as publicacións da usuaria.",
"account.name.help.domain_self": "{domain} é o servidor que aloxa o teu perfil e publicacións.",
"account.name.help.footer": "Ao igual que envías correos electrónicos a persoas que usan diferentes provedores, podes interactuar con persoas en outros servidores Mastodon ― e con calquera outra que use aplicacións que sigan o mesmo sistema que usa Mastodon (o protocolo ActivityPub).",
"account.name.help.header": "Un alcume é o como o enderezo de correo",
"account.name.help.username": "{username} é o nome de usuaria da conta no seu servidor. Alguén noutro servidor podería ter o mesmo nome de usuaria.",
"account.name.help.username_self": "{username} é o teu nome de usuaria nete servidor. Alguén noutro sevidor podería ter o mesmo nome de usuaria.",
"account.name_info": "Isto que significa?",
"account.no_bio": "Sen descrición.", "account.no_bio": "Sen descrición.",
"account.node_modal.callout": "As notas persoais só as podes ver ti.", "account.node_modal.callout": "As notas persoais só as podes ver ti.",
"account.node_modal.edit_title": "Editar nota persoal", "account.node_modal.edit_title": "Editar nota persoal",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "הסתר הדהודים בקו הזמן", "account.menu.hide_reblogs": "הסתר הדהודים בקו הזמן",
"account.menu.mention": "אזכור", "account.menu.mention": "אזכור",
"account.menu.mute": "השתקת חשבון", "account.menu.mute": "השתקת חשבון",
"account.menu.note.description": "גלוי לך בלבד",
"account.menu.open_original_page": "לצפות אצל {domain}", "account.menu.open_original_page": "לצפות אצל {domain}",
"account.menu.remove_follower": "הסרת עוקב", "account.menu.remove_follower": "הסרת עוקב",
"account.menu.report": "דווח על חשבון", "account.menu.report": "דווח על חשבון",
@ -104,6 +105,13 @@
"account.muted": "מושתק", "account.muted": "מושתק",
"account.muting": "רשימת החשבונות המושתקים", "account.muting": "רשימת החשבונות המושתקים",
"account.mutual": "אתם עוקביםות הדדית", "account.mutual": "אתם עוקביםות הדדית",
"account.name.help.domain": "{domain} הוא השרת שמארח את פרופיל המשתמש(ת) וההודעות שכתב(ה).",
"account.name.help.domain_self": "{domain} הוא השרת שמארח את פרופיל המשתמש(ת) שלך ואת ההודעות שכתבת.",
"account.name.help.footer": "בדיוק כמו שתוכלו לשלוח דואל לאנשים דרך תוכנות דואל שונות, ניתן להיות בקשר עם אנשים על שרתי מסטודון וכל אדם בשרתי פדיברס (המשתמשים בפרוטוכול אקטיביטיפאב ActivityPub).",
"account.name.help.header": "כינוי הוא כמו כתובת דואל",
"account.name.help.username": "{username} הוא שם המשתמש של החשבון בשרת שלהם. מישהו משרת אחר יכול להחזיק באותו שם משתמש.",
"account.name.help.username_self": "{username} הוא שם המשתמש של החשבון שלכם בשרת זה. מישהו משרת אחר יכול להחזיק באותו שם משתמש.",
"account.name_info": "מה זה אומר?",
"account.no_bio": "לא סופק תיאור.", "account.no_bio": "לא סופק תיאור.",
"account.node_modal.callout": "הערות פרטיות גלויות לך בלבד.", "account.node_modal.callout": "הערות פרטיות גלויות לך בלבד.",
"account.node_modal.edit_title": "עריכת הערה פרטית", "account.node_modal.edit_title": "עריכת הערה פרטית",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Fela endurbirtingar í tímalínu", "account.menu.hide_reblogs": "Fela endurbirtingar í tímalínu",
"account.menu.mention": "Minnst á", "account.menu.mention": "Minnst á",
"account.menu.mute": "Þagga niður í aðgangi", "account.menu.mute": "Þagga niður í aðgangi",
"account.menu.note.description": "Einungis sýnilegt þér",
"account.menu.open_original_page": "Skoða á {domain}", "account.menu.open_original_page": "Skoða á {domain}",
"account.menu.remove_follower": "Fjarlægja fylgjanda", "account.menu.remove_follower": "Fjarlægja fylgjanda",
"account.menu.report": "Kæra aðgang", "account.menu.report": "Kæra aðgang",
@ -104,7 +105,14 @@
"account.muted": "Þaggaður", "account.muted": "Þaggaður",
"account.muting": "Þöggun", "account.muting": "Þöggun",
"account.mutual": "Þið fylgist með hvor öðrum", "account.mutual": "Þið fylgist með hvor öðrum",
"account.no_bio": "Engri lýsingu útvegað.", "account.name.help.domain": "{domain} er netþjónninn sem hýsir upplýsingasnið um notandann og færslurnar hans.",
"account.name.help.domain_self": "{domain} er netþjónninn þinn sem hýsir upplýsingasniðið þitt og færslurnar þínar.",
"account.name.help.footer": "Rétt eins og að þú getur sent tölvupóst á fólk í gegnum mismunandi þjónustur og forrit, þá getur þú átt í samskiptum við fólk á öðrum Mastodon-netþjónum og reyndar við hverja þá sem eru á öðrum þeim samfélagsmiðlum sem nota sömu samskiptareglur og Mastodon notar (sem er ActivityPub-samskiptamátinn).",
"account.name.help.header": "Kennislóð (handle) líkist tölvupóstfangi",
"account.name.help.username": "{username} er notandanafn þessa aðgangs á netþjóni viðkomandi. Einhver annar á öðrum netþjóni getur verið með sama notandanafnið.",
"account.name.help.username_self": "{username} er notandanafnið þitt á þessum netþjóni. Einhver annar á öðrum netþjóni getur verið með sama notandanafnið.",
"account.name_info": "Hvað þýðir þetta?",
"account.no_bio": "Engin lýsing gefin upp.",
"account.node_modal.callout": "Persónulegir einkaminnispunktar eru einungis sýnilegir þér.", "account.node_modal.callout": "Persónulegir einkaminnispunktar eru einungis sýnilegir þér.",
"account.node_modal.edit_title": "Breyta einkaminnispunkti", "account.node_modal.edit_title": "Breyta einkaminnispunkti",
"account.node_modal.error_unknown": "Tókst ekki að vista minnispunktinn", "account.node_modal.error_unknown": "Tókst ekki að vista minnispunktinn",

View File

@ -11,7 +11,7 @@
"about.domain_blocks.suspended.title": "Sospeso", "about.domain_blocks.suspended.title": "Sospeso",
"about.language_label": "Lingua", "about.language_label": "Lingua",
"about.not_available": "Queste informazioni non sono state rese disponibili su questo server.", "about.not_available": "Queste informazioni non sono state rese disponibili su questo server.",
"about.powered_by": "Social media decentralizzato alimentato da {mastodon}", "about.powered_by": "Social media decentralizzato basato su {mastodon}",
"about.rules": "Regole del server", "about.rules": "Regole del server",
"account.account_note_header": "Note personali", "account.account_note_header": "Note personali",
"account.activity": "Attività", "account.activity": "Attività",
@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Nascondi le condivisioni nella timeline", "account.menu.hide_reblogs": "Nascondi le condivisioni nella timeline",
"account.menu.mention": "Menziona", "account.menu.mention": "Menziona",
"account.menu.mute": "Silenzia l'account", "account.menu.mute": "Silenzia l'account",
"account.menu.note.description": "Visibile solo a te",
"account.menu.open_original_page": "Visualizza su {domain}", "account.menu.open_original_page": "Visualizza su {domain}",
"account.menu.remove_follower": "Rimuovi il seguace", "account.menu.remove_follower": "Rimuovi il seguace",
"account.menu.report": "Segnala l'account", "account.menu.report": "Segnala l'account",
@ -104,6 +105,13 @@
"account.muted": "Mutato", "account.muted": "Mutato",
"account.muting": "Account silenziato", "account.muting": "Account silenziato",
"account.mutual": "Vi seguite a vicenda", "account.mutual": "Vi seguite a vicenda",
"account.name.help.domain": "{domain} è il server che ospita il profilo e i post dell'utente.",
"account.name.help.domain_self": "{domain} è il tuo server che ospita il tuo profilo e i post.",
"account.name.help.footer": "Proprio come puoi inviare email a persone che usano diversi client di posta elettronica, così puoi interagire con le persone su altri server Mastodon — e con chiunque utilizzi altre app social basate sulle stesse regole che Mastodon usa (il protocollo ActivityPub).",
"account.name.help.header": "Un nome univoco è come un indirizzo email",
"account.name.help.username": "{username} è il nome utente di questo account sul suo server. Qualcuno su un altro server potrebbe avere lo stesso nome utente.",
"account.name.help.username_self": "{username} è il tuo nome utente su questo server. Qualcuno su un altro server potrebbe avere lo stesso nome utente.",
"account.name_info": "Cosa significa questo?",
"account.no_bio": "Nessuna descrizione fornita.", "account.no_bio": "Nessuna descrizione fornita.",
"account.node_modal.callout": "Le note personali sono visibili solo per te.", "account.node_modal.callout": "Le note personali sono visibili solo per te.",
"account.node_modal.edit_title": "Modifica la nota personale", "account.node_modal.edit_title": "Modifica la nota personale",

View File

@ -83,20 +83,21 @@
"account.menu.add_to_list": "Aan lijst toevoegen…", "account.menu.add_to_list": "Aan lijst toevoegen…",
"account.menu.block": "Account blokkeren", "account.menu.block": "Account blokkeren",
"account.menu.block_domain": "{domain} blokkeren", "account.menu.block_domain": "{domain} blokkeren",
"account.menu.copied": "Accountlink gekopieerd naar het klembord", "account.menu.copied": "Accountlink naar het klembord gekopieerd",
"account.menu.copy": "Link kopiëren", "account.menu.copy": "Link kopiëren",
"account.menu.direct": "Privé vermelden", "account.menu.direct": "Privébericht",
"account.menu.hide_reblogs": "Boosts verbergen in tijdlijn", "account.menu.hide_reblogs": "Boosts in tijdlijn verbergen",
"account.menu.mention": "Vermelding", "account.menu.mention": "Vermelding",
"account.menu.mute": "Account negeren", "account.menu.mute": "Account negeren",
"account.menu.open_original_page": "Bekijken op {domain}", "account.menu.note.description": "Alleen voor jou zichtbaar",
"account.menu.open_original_page": "Op {domain} bekijken",
"account.menu.remove_follower": "Volger verwijderen", "account.menu.remove_follower": "Volger verwijderen",
"account.menu.report": "Account rapporteren", "account.menu.report": "Account rapporteren",
"account.menu.share": "Delen…", "account.menu.share": "Delen…",
"account.menu.show_reblogs": "Boosts in tijdlijn tonen", "account.menu.show_reblogs": "Boosts in tijdlijn tonen",
"account.menu.unblock": "Account deblokkeren", "account.menu.unblock": "Account deblokkeren",
"account.menu.unblock_domain": "{domain} deblokkeren", "account.menu.unblock_domain": "{domain} deblokkeren",
"account.menu.unmute": "Negeren van account opheffen", "account.menu.unmute": "Account niet langer negeren",
"account.moved_to": "{name} is verhuisd naar:", "account.moved_to": "{name} is verhuisd naar:",
"account.mute": "@{name} negeren", "account.mute": "@{name} negeren",
"account.mute_notifications_short": "Meldingen negeren", "account.mute_notifications_short": "Meldingen negeren",
@ -104,6 +105,13 @@
"account.muted": "Genegeerd", "account.muted": "Genegeerd",
"account.muting": "Genegeerd", "account.muting": "Genegeerd",
"account.mutual": "Jullie volgen elkaar", "account.mutual": "Jullie volgen elkaar",
"account.name.help.domain": "{domain} is de server waar zich het profiel en de berichten van de gebruiker bevinden.",
"account.name.help.domain_self": "{domain} is jouw server waar zich jouw profiel en berichten bevinden.",
"account.name.help.footer": "Net zoals je e-mails kunt verzenden naar mensen met verschillende e-mailproviders, kun je interactie hebben met mensen op andere Mastodon-servers en met iedereen op andere social apps, die met behulp van het ActivityPub-protocol met Mastodon kunnen communiceren.",
"account.name.help.header": "Een fediverse-adres valt te vergelijken met een e-mailadres",
"account.name.help.username": "{username} is de gebruikersnaam van het account op hun server. Het is mogelijk dat iemand anders dezelfde gebruikersnaam op een andere server heeft.",
"account.name.help.username_self": "{username} is jouw gebruikersnaam op deze server. Het is mogelijk dat iemand anders dezelfde gebruikersnaam op een andere server heeft.",
"account.name_info": "Wat betekent dit?",
"account.no_bio": "Geen beschrijving opgegeven.", "account.no_bio": "Geen beschrijving opgegeven.",
"account.node_modal.callout": "Persoonlijke opmerkingen zijn alleen zichtbaar voor jou.", "account.node_modal.callout": "Persoonlijke opmerkingen zijn alleen zichtbaar voor jou.",
"account.node_modal.edit_title": "Persoonlijke opmerking bewerken", "account.node_modal.edit_title": "Persoonlijke opmerking bewerken",
@ -241,15 +249,15 @@
"collections.collection_topic": "Onderwerp", "collections.collection_topic": "Onderwerp",
"collections.content_warning": "Inhoudswaarschuwing", "collections.content_warning": "Inhoudswaarschuwing",
"collections.continue": "Doorgaan", "collections.continue": "Doorgaan",
"collections.create.accounts_subtitle": "Alleen accounts die je volgt die voor ontdekking gekozen hebben, kunnen worden toegevoegd.", "collections.create.accounts_subtitle": "Alleen accounts die je volgt en ontdekt willen worden, kunnen worden toegevoegd.",
"collections.create.accounts_title": "Wie ga je laten zien in deze verzameling?", "collections.create.accounts_title": "Wie wil je aan deze verzameling toevoegen?",
"collections.create.basic_details_title": "Basisgegevens", "collections.create.basic_details_title": "Basisgegevens",
"collections.create.settings_title": "Instellingen", "collections.create.settings_title": "Instellingen",
"collections.create.steps": "Stap {step}/{total}", "collections.create.steps": "Stap {step}/{total}",
"collections.create_a_collection_hint": "Een verzameling aanmaken om je favoriete accounts aan te bevelen of te delen met anderen.", "collections.create_a_collection_hint": "Een verzameling aanmaken om je favoriete accounts aan te bevelen of te delen met anderen.",
"collections.create_collection": "Verzameling aanmaken", "collections.create_collection": "Verzameling aanmaken",
"collections.delete_collection": "Verzameling verwijderen", "collections.delete_collection": "Verzameling verwijderen",
"collections.description_length_hint": "100 tekens limiet", "collections.description_length_hint": "Maximaal 100 karakters",
"collections.edit_details": "Basisgegevens bewerken", "collections.edit_details": "Basisgegevens bewerken",
"collections.edit_settings": "Instellingen bewerken", "collections.edit_settings": "Instellingen bewerken",
"collections.error_loading_collections": "Er is een fout opgetreden bij het laden van je verzamelingen.", "collections.error_loading_collections": "Er is een fout opgetreden bij het laden van je verzamelingen.",
@ -263,10 +271,10 @@
"collections.topic_hint": "Voeg een hashtag toe die anderen helpt het hoofdonderwerp van deze collectie te begrijpen.", "collections.topic_hint": "Voeg een hashtag toe die anderen helpt het hoofdonderwerp van deze collectie te begrijpen.",
"collections.view_collection": "Verzameling bekijken", "collections.view_collection": "Verzameling bekijken",
"collections.visibility_public": "Openbaar", "collections.visibility_public": "Openbaar",
"collections.visibility_public_hint": "Ontdekbaar in zoekresultaten en andere gebieden waar aanbevelingen verschijnen.", "collections.visibility_public_hint": "Te zien onder zoekresultaten en in andere gebieden waar aanbevelingen verschijnen.",
"collections.visibility_title": "Zichbaarheid", "collections.visibility_title": "Zichtbaarheid",
"collections.visibility_unlisted": "Niet weergegeven", "collections.visibility_unlisted": "Niet zichtbaar",
"collections.visibility_unlisted_hint": "Zichtbaar voor iedereen met een link. Verborgen voor zoekresultaten en aanbevelingen.", "collections.visibility_unlisted_hint": "Zichtbaar voor iedereen met een link. Verborgen onder zoekresultaten en aanbevelingen.",
"column.about": "Over", "column.about": "Over",
"column.blocks": "Geblokkeerde gebruikers", "column.blocks": "Geblokkeerde gebruikers",
"column.bookmarks": "Bladwijzers", "column.bookmarks": "Bladwijzers",
@ -301,7 +309,7 @@
"combobox.loading": "Laden", "combobox.loading": "Laden",
"combobox.no_results_found": "Geen resultaten voor deze zoekopdracht", "combobox.no_results_found": "Geen resultaten voor deze zoekopdracht",
"combobox.open_results": "Resultaten openen", "combobox.open_results": "Resultaten openen",
"combobox.results_available": "{count, plural, one {# suggesties} other {# suggesties}} beschikbaar. Gebruik de pijltjes omhoog en omlaag om te navigeren. Druk op Enter om te selecteren.", "combobox.results_available": "{count, plural, one {# suggestie} other {# suggesties}} beschikbaar. Gebruik de pijltjes omhoog en omlaag om te navigeren. Druk op Enter om te selecteren.",
"community.column_settings.local_only": "Alleen lokaal", "community.column_settings.local_only": "Alleen lokaal",
"community.column_settings.media_only": "Alleen media", "community.column_settings.media_only": "Alleen media",
"community.column_settings.remote_only": "Alleen andere servers", "community.column_settings.remote_only": "Alleen andere servers",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Zaman çizelgesinde yeniden gönderimleri gizle", "account.menu.hide_reblogs": "Zaman çizelgesinde yeniden gönderimleri gizle",
"account.menu.mention": "Bahset", "account.menu.mention": "Bahset",
"account.menu.mute": "Hesabı sessize al", "account.menu.mute": "Hesabı sessize al",
"account.menu.note.description": "Sadece sizin tarafınızdan görülebilir",
"account.menu.open_original_page": "{domain} alan adında görüntüle", "account.menu.open_original_page": "{domain} alan adında görüntüle",
"account.menu.remove_follower": "Takipçiyi kaldır", "account.menu.remove_follower": "Takipçiyi kaldır",
"account.menu.report": "Hesabı bildir", "account.menu.report": "Hesabı bildir",
@ -104,6 +105,13 @@
"account.muted": "Susturuldu", "account.muted": "Susturuldu",
"account.muting": "Sessize alınıyor", "account.muting": "Sessize alınıyor",
"account.mutual": "Birbirinizi takip ediyorsunuz", "account.mutual": "Birbirinizi takip ediyorsunuz",
"account.name.help.domain": "{domain} kullanıcının profilini ve gönderilerini barındıran sunucudur.",
"account.name.help.domain_self": "{domain} profilinizi ve gönderilerinizi barındıran sunucunuzdur.",
"account.name.help.footer": "Farklı e-posta istemcileri kullanan kişilere e-posta gönderebileceğiniz gibi, diğer Mastodon sunucularındaki kişilerle ve Mastodon'un kullandığı kurallarla (ActivityPub protokolü) çalışan diğer sosyal uygulamalardaki herkesle etkileşim kurabilirsiniz.",
"account.name.help.header": "Takma ad bir e-posta adresi gibidir",
"account.name.help.username": "{username} bu hesabın sunucudaki kullanıcı adıdır. Başka bir sunucudaki başka bir kişi de aynı kullanıcı adına sahip olabilir.",
"account.name.help.username_self": "{username} bu sunucudaki kullanıcı adınızdır. Başka bir sunucudaki başka bir kullanıcı da aynı kullanıcı adına sahip olabilir.",
"account.name_info": "Bu ne anlama geliyor?",
"account.no_bio": "Herhangi bir açıklama belirtilmedi.", "account.no_bio": "Herhangi bir açıklama belirtilmedi.",
"account.node_modal.callout": "Kişisel notları sadece siz görüntüleyebilirsiniz.", "account.node_modal.callout": "Kişisel notları sadece siz görüntüleyebilirsiniz.",
"account.node_modal.edit_title": "Kişisel notu düzenle", "account.node_modal.edit_title": "Kişisel notu düzenle",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Ẩn đăng lại trên bảng tin", "account.menu.hide_reblogs": "Ẩn đăng lại trên bảng tin",
"account.menu.mention": "Nhắc đến", "account.menu.mention": "Nhắc đến",
"account.menu.mute": "Phớt lờ tài khoản", "account.menu.mute": "Phớt lờ tài khoản",
"account.menu.note.description": "Chỉ hiển thị với bạn",
"account.menu.open_original_page": "Xem trên {domain}", "account.menu.open_original_page": "Xem trên {domain}",
"account.menu.remove_follower": "Xóa người theo dõi", "account.menu.remove_follower": "Xóa người theo dõi",
"account.menu.report": "Báo cáo tài khoản", "account.menu.report": "Báo cáo tài khoản",
@ -104,6 +105,13 @@
"account.muted": "Đã phớt lờ", "account.muted": "Đã phớt lờ",
"account.muting": "Đang ẩn", "account.muting": "Đang ẩn",
"account.mutual": "Theo dõi nhau", "account.mutual": "Theo dõi nhau",
"account.name.help.domain": "{domain} là máy chủ lưu trữ hồ sơ và tút của người dùng.",
"account.name.help.domain_self": "{domain} là máy chủ lưu trữ hồ sơ và tút của bạn.",
"account.name.help.footer": "Giống như bạn có thể gửi email cho mọi người bằng các dịch vụ email khác nhau, bạn có thể tương tác với mọi người trên các máy chủ Mastodon khác và với bất kỳ ai trên các ứng dụng xã hội khác được cung cấp bởi cùng một bộ quy tắc mà Mastodon sử dụng (giao thức ActivityPub).",
"account.name.help.header": "Một địa chỉ giống như một địa chỉ email",
"account.name.help.username": "{username} là tên người dùng của tài khoản này trên máy chủ của họ. Trên máy chủ khác có thể có tên người dùng giống vậy.",
"account.name.help.username_self": "{username} là tên người dùng của bạn trên máy chủ này. Trên máy chủ khác có thể có tên người dùng giống bạn.",
"account.name_info": "Điều này nghĩa là gì?",
"account.no_bio": "Chưa có miêu tả.", "account.no_bio": "Chưa có miêu tả.",
"account.node_modal.callout": "Các ghi chú chỉ hiển thị với bạn.", "account.node_modal.callout": "Các ghi chú chỉ hiển thị với bạn.",
"account.node_modal.edit_title": "Sửa ghi chú", "account.node_modal.edit_title": "Sửa ghi chú",

View File

@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "於時間軸中隱藏轉嘟", "account.menu.hide_reblogs": "於時間軸中隱藏轉嘟",
"account.menu.mention": "提及", "account.menu.mention": "提及",
"account.menu.mute": "靜音帳號", "account.menu.mute": "靜音帳號",
"account.menu.note.description": "僅有您可見",
"account.menu.open_original_page": "於 {domain} 檢視", "account.menu.open_original_page": "於 {domain} 檢視",
"account.menu.remove_follower": "移除跟隨者", "account.menu.remove_follower": "移除跟隨者",
"account.menu.report": "檢舉帳號", "account.menu.report": "檢舉帳號",
@ -104,6 +105,13 @@
"account.muted": "已靜音", "account.muted": "已靜音",
"account.muting": "靜音", "account.muting": "靜音",
"account.mutual": "跟隨彼此", "account.mutual": "跟隨彼此",
"account.name.help.domain": "{domain} 為託管此使用者個人檔案與嘟文之伺服器。",
"account.name.help.domain_self": "{domain} 為託管您個人檔案與嘟文之伺服器。",
"account.name.help.footer": "就像是您的以不同 email 程式寄信給他人一樣,您能與其他 Mastodon 伺服器中的人們互動,並且能和使用與 Mastodon 一樣規則 (ActivityPub 協定) 的其他社群軟體上任何人互動。",
"account.name.help.header": "@handle 就像是 email 地址一樣",
"account.name.help.username": "{username} 為該使用者於他們伺服器上的帳號名稱。其他伺服器上的某個人可能擁有一樣的使用者名稱。",
"account.name.help.username_self": "{username} 為您於此伺服器上的帳號名稱。其他伺服器上的某個人可能擁有一樣的使用者名稱。",
"account.name_info": "這是什麼?",
"account.no_bio": "無個人檔案描述。", "account.no_bio": "無個人檔案描述。",
"account.node_modal.callout": "僅有您能見到您的個人備註。", "account.node_modal.callout": "僅有您能見到您的個人備註。",
"account.node_modal.edit_title": "編輯個人備註", "account.node_modal.edit_title": "編輯個人備註",

View File

@ -292,7 +292,7 @@ function shouldMarkNewNotificationsAsRead(
function updateLastReadId( function updateLastReadId(
state: NotificationGroupsState, state: NotificationGroupsState,
group: NotificationGroup | undefined = undefined, group?: NotificationGroup,
) { ) {
if (shouldMarkNewNotificationsAsRead(state)) { if (shouldMarkNewNotificationsAsRead(state)) {
group = group ?? state.groups.find(isNotificationGroup); group = group ?? state.groups.find(isNotificationGroup);

View File

@ -20,7 +20,12 @@ import {
TIMELINE_GAP, TIMELINE_GAP,
disconnectTimeline, disconnectTimeline,
} from '../actions/timelines'; } from '../actions/timelines';
import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; import {
timelineDelete,
timelineDeleteStatus,
isTimelineKeyPinned,
isNonStatusId,
} from '../actions/timelines_typed';
import { compareId } from '../compare_id'; import { compareId } from '../compare_id';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -145,6 +150,11 @@ const deleteStatus = (state, id, references, exclude_account = null) => {
return state; return state;
}; };
const deleteStatusFromTimeline = (state, statusId, timelineKey) => {
const helper = list => list.filterNot((status) => status === statusId);
return state.updateIn([timelineKey, 'items'], helper).updateIn([timelineKey, 'pendingItems'], helper);
}
const clearTimeline = (state, timeline) => { const clearTimeline = (state, timeline) => {
return state.set(timeline, initialTimeline); return state.set(timeline, initialTimeline);
}; };
@ -200,25 +210,12 @@ export default function timelines(state = initialState, action) {
return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems);
case TIMELINE_UPDATE: case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems);
case timelineDelete.type:
return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf);
case TIMELINE_CLEAR: case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline); return clearTimeline(state, action.timeline);
case blockAccountSuccess.type:
case muteAccountSuccess.type:
return filterTimelines(state, action.payload.relationship, action.payload.statuses);
case unfollowAccountSuccess.type:
return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
case TIMELINE_SCROLL_TOP: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT: case TIMELINE_CONNECT:
return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems)); return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems));
case disconnectTimeline.type:
return state.update(
action.payload.timeline,
initialTimeline,
map => map.set('online', false).update(action.payload.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items),
);
case TIMELINE_MARK_AS_PARTIAL: case TIMELINE_MARK_AS_PARTIAL:
return state.update( return state.update(
action.timeline, action.timeline,
@ -238,6 +235,29 @@ export default function timelines(state = initialState, action) {
}) })
); );
default: default:
if (timelineDelete.match(action)) {
return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf);
} else if (timelineDeleteStatus.match(action)) {
return deleteStatusFromTimeline(state, action.payload.statusId, action.payload.timelineKey);
} else if (blockAccountSuccess.match(action) || muteAccountSuccess.match(action)) {
return filterTimelines(state, action.payload.relationship, action.payload.statuses);
} else if (unfollowAccountSuccess.match(action)) {
return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
} else if (disconnectTimeline.match(action)) {
return state.update(
action.payload.timeline,
initialTimeline,
(map) => map.set('online', false).update(
action.payload.usePendingItems
? 'pendingItems'
: 'items',
items => items.first()
? items.unshift(TIMELINE_GAP)
: items
),
);
}
return state; return state;
} }
} }

View File

@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap<string, unknown>) {
emptyList, emptyList,
) as ImmutableList<string>, ) as ImmutableList<string>,
items: timeline.get('items', emptyList) as ImmutableList<string>, items: timeline.get('items', emptyList) as ImmutableList<string>,
} as TimelineShape; } satisfies TimelineShape;
} }

View File

@ -0,0 +1,35 @@
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { createAppSelector } from '../store';
export const selectUserListWithoutMe = createAppSelector(
[
(state) => state.user_lists,
(state) => (state.meta.get('me') as string | null) ?? null,
(_state, listName: string) => listName,
(_state, _listName, accountId?: string | null) => accountId ?? null,
],
(lists, currentAccountId, listName, accountId) => {
if (!accountId || !listName) {
return null;
}
const list = lists.getIn([listName, accountId]) as
| ImmutableMap<string, unknown>
| undefined;
if (!list) {
return null;
}
// Returns the list except the current account.
return {
items: (
list.get('items', ImmutableList<string>()) as ImmutableList<string>
)
.filter((id) => id !== currentAccountId)
.toArray(),
isLoading: !!list.get('isLoading', true),
hasMore: !!list.get('hasMore', false),
};
},
);

View File

@ -226,7 +226,11 @@ class User < ApplicationRecord
end end
def functional_or_moved? def functional_or_moved?
confirmed? && approved? && !disabled? && !account.unavailable? && !account.memorial? confirmed? && approved? && !disabled? && !account.unavailable? && !account.memorial? && !missing_2fa?
end
def missing_2fa?
!two_factor_enabled? && role.require_2fa?
end end
def unconfirmed_or_pending? def unconfirmed_or_pending?

View File

@ -5,11 +5,12 @@
# Table name: user_roles # Table name: user_roles
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# name :string default(""), not null
# color :string default(""), not null # color :string default(""), not null
# position :integer default(0), not null
# permissions :bigint(8) default(0), not null
# highlighted :boolean default(FALSE), not null # highlighted :boolean default(FALSE), not null
# name :string default(""), not null
# permissions :bigint(8) default(0), not null
# position :integer default(0), not null
# require_2fa :boolean default(FALSE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# #
@ -160,7 +161,7 @@ class UserRole < ApplicationRecord
@computed_permissions ||= begin @computed_permissions ||= begin
permissions = self.class.everyone.permissions | self.permissions permissions = self.class.everyone.permissions | self.permissions
if permissions & FLAGS[:administrator] == FLAGS[:administrator] if administrator?
Flags::ALL Flags::ALL
else else
permissions permissions
@ -172,6 +173,10 @@ class UserRole < ApplicationRecord
name name
end end
def administrator?
permissions & FLAGS[:administrator] == FLAGS[:administrator]
end
private private
def in_permissions?(privilege) def in_permissions?(privilege)
@ -189,6 +194,7 @@ class UserRole < ApplicationRecord
errors.add(:permissions_as_keys, :own_role) if permissions_changed? errors.add(:permissions_as_keys, :own_role) if permissions_changed?
errors.add(:position, :own_role) if position_changed? errors.add(:position, :own_role) if position_changed?
errors.add(:require_2fa, :own_role) if require_2fa_changed? && !administrator?
end end
def validate_permissions_elevation def validate_permissions_elevation

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class ActivityPub::AddFeaturedItemSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :type, :actor, :target
has_one :object, serializer: ActivityPub::FeaturedItemSerializer
def type
'Add'
end
def actor
ActivityPub::TagManager.instance.uri_for(collection.account)
end
def target
ActivityPub::TagManager.instance.uri_for(collection)
end
private
def collection
@collection ||= object.collection
end
end

View File

@ -1,22 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
class FeaturedItemSerializer < ActivityPub::Serializer
attributes :type, :featured_object, :featured_object_type
def type
'FeaturedItem'
end
def featured_object
ActivityPub::TagManager.instance.uri_for(object.account)
end
def featured_object_type
object.account.actor_type || 'Person'
end
end
attributes :id, :type, :total_items, :name, :attributed_to, attributes :id, :type, :total_items, :name, :attributed_to,
:sensitive, :discoverable, :published, :updated :sensitive, :discoverable, :published, :updated
@ -25,7 +9,7 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
has_one :tag, key: :topic, serializer: ActivityPub::NoteSerializer::TagSerializer has_one :tag, key: :topic, serializer: ActivityPub::NoteSerializer::TagSerializer
has_many :collection_items, key: :ordered_items, serializer: FeaturedItemSerializer has_many :collection_items, key: :ordered_items, serializer: ActivityPub::FeaturedItemSerializer
def id def id
ActivityPub::TagManager.instance.uri_for(object) ActivityPub::TagManager.instance.uri_for(object)

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class ActivityPub::FeaturedItemSerializer < ActivityPub::Serializer
attributes :type, :featured_object, :featured_object_type
def type
'FeaturedItem'
end
def featured_object
ActivityPub::TagManager.instance.uri_for(object.account)
end
def featured_object_type
object.account.actor_type || 'Person'
end
end

View File

@ -12,7 +12,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService
@json = fetch_resource(uri, false) @json = fetch_resource(uri, false)
raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil?
raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) || (supported_security_context?(@json) && @json['owner'].present? && !actor_type?)
raise Error, "Unexpected object type for key #{uri}" unless expected_type? raise Error, "Unexpected object type for key #{uri}" unless expected_type?
return find_actor(@json['id'], @json, suppress_errors) if actor_type? return find_actor(@json['id'], @json, suppress_errors) if actor_type?

View File

@ -9,7 +9,11 @@ class AddAccountToCollectionService
raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@collection.account, @account).feature? raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@collection.account, @account).feature?
create_collection_item @collection_item = create_collection_item
distribute_add_activity if @account.local? && Mastodon::Feature.collections_federation_enabled?
@collection_item
end end
private private
@ -20,4 +24,12 @@ class AddAccountToCollectionService
state: :accepted state: :accepted
) )
end end
def distribute_add_activity
ActivityPub::AccountRawDistributionWorker.perform_async(activity_json, @collection.account_id)
end
def activity_json
ActiveModelSerializers::SerializableResource.new(@collection_item, serializer: ActivityPub::AddFeaturedItemSerializer, adapter: ActivityPub::Adapter).to_json
end
end end

View File

@ -25,6 +25,11 @@
= form.input :highlighted, = form.input :highlighted,
wrapper: :with_label wrapper: :with_label
- if current_user.role.administrator? || current_user.role != form.object
.fields-group
= form.input :require_2fa,
wrapper: :with_label
%hr.spacer/ %hr.spacer/
- unless current_user.role == form.object - unless current_user.role == form.object

View File

@ -21,9 +21,13 @@
.announcements-list__item__meta .announcements-list__item__meta
- if role.everyone? - if role.everyone?
= t('admin.roles.everyone_full_description_html') = t('admin.roles.everyone_full_description_html')
= t('admin.roles.requires_2fa') if role.require_2fa?
- else - else
= link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id) = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
· ·
%abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size) %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
- if role.require_2fa?
·
= t('admin.roles.requires_2fa')
.announcements-list__item__actions .announcements-list__item__actions
= table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role) = table_link_to 'edit', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)

View File

@ -12,6 +12,8 @@
%samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ') %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group .fields-group
= hidden_field_tag :oauth, params[:oauth]
= f.input :otp_attempt, = f.input :otp_attempt,
hint: t('otp_authentication.code_hint'), hint: t('otp_authentication.code_hint'),
input_html: { autocomplete: 'off' }, input_html: { autocomplete: 'off' },

View File

@ -1,9 +1,12 @@
- content_for :page_title do - content_for :page_title do
= t('settings.two_factor_authentication') = t('settings.two_factor_authentication')
- if current_user.role.require_2fa?
.flash-message= t('two_factor_authentication.role_requirement', domain: site_hostname)
.simple_form .simple_form
%p.hint= t('otp_authentication.description_html') %p.hint= t('otp_authentication.description_html')
%hr.spacer/ %hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'button button--block' = link_to t('otp_authentication.setup'), settings_otp_authentication_path(params.permit(:oauth)), data: { method: :post }, class: 'button button--block'

View File

@ -7,3 +7,9 @@
- @recovery_codes.each do |code| - @recovery_codes.each do |code|
%li< %li<
%samp= code %samp= code
- if params[:oauth]
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.resume_app_authorization'), return_to_app_url, class: 'button button--block'

View File

@ -2,7 +2,10 @@
= t('settings.two_factor_authentication') = t('settings.two_factor_authentication')
- content_for :heading_actions do - content_for :heading_actions do
= link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post = link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post unless current_user.role.require_2fa?
- if current_user.role.require_2fa?
.flash-message= t('two_factor_authentication.role_requirement', domain: site_hostname)
%p.hint %p.hint
%span.positive-hint %span.positive-hint

View File

@ -1444,10 +1444,6 @@ an:
too_late: Ye masiau tarde pa apelar esta amonestación too_late: Ye masiau tarde pa apelar esta amonestación
tags: tags:
does_not_match_previous_name: no coincide con o nombre anterior does_not_match_previous_name: no coincide con o nombre anterior
themes:
contrast: Mastodon (Alto contraste)
default: Mastodon (Fosco)
mastodon-light: Mastodon (claro)
time: time:
formats: formats:
default: "%d de %b de %Y, %H:%M" default: "%d de %b de %Y, %H:%M"

View File

@ -898,6 +898,10 @@ ar:
all: للجميع all: للجميع
disabled: لا أحد disabled: لا أحد
users: للمستخدمين المتصلين محليا users: للمستخدمين المتصلين محليا
landing_page:
values:
about: عن
trends: المتداوَلة
registrations: registrations:
moderation_recommandation: الرجاء التأكد من أن لديك فريق إشراف كافي وفعال قبل فتح التسجيلات للجميع! moderation_recommandation: الرجاء التأكد من أن لديك فريق إشراف كافي وفعال قبل فتح التسجيلات للجميع!
preamble: تحكّم في مَن الذي يمكنه إنشاء حساب على خادمك الخاص. preamble: تحكّم في مَن الذي يمكنه إنشاء حساب على خادمك الخاص.
@ -951,6 +955,7 @@ ar:
no_status_selected: لم يطرأ أي تغيير على أي منشور بما أنه لم يتم اختيار أي واحد no_status_selected: لم يطرأ أي تغيير على أي منشور بما أنه لم يتم اختيار أي واحد
open: افتح المنشور open: افتح المنشور
original_status: المنشور الأصلي original_status: المنشور الأصلي
quotes: الاقتباسات
reblogs: المعاد تدوينها reblogs: المعاد تدوينها
replied_to_html: تم الرد على %{acct_link} replied_to_html: تم الرد على %{acct_link}
status_changed: عُدّل المنشور status_changed: عُدّل المنشور
@ -958,6 +963,7 @@ ar:
title: منشورات الحساب - @%{name} title: منشورات الحساب - @%{name}
trending: المتداولة trending: المتداولة
view_publicly: رؤية الجزء العلني view_publicly: رؤية الجزء العلني
view_quoted_post: عرض المنشور المقتبس
visibility: مدى الظهور visibility: مدى الظهور
with_media: تحتوي على وسائط with_media: تحتوي على وسائط
strikes: strikes:
@ -1251,6 +1257,7 @@ ar:
hint_html: إذا كنت ترغب في الانتقال من حساب آخر إلى هذا الحساب الحالي، يمكنك إنشاء اسم مستعار هنا، والذي هو مطلوب قبل أن تتمكن من المضي قدما في نقل متابِعيك من الحساب القديم إلى هذا الحساب. هذا الإجراء بحد ذاته هو <strong>غير مؤذي و قابل للعكس</strong>. <strong>تتم بداية تهجير الحساب من الحساب القديم</strong>. hint_html: إذا كنت ترغب في الانتقال من حساب آخر إلى هذا الحساب الحالي، يمكنك إنشاء اسم مستعار هنا، والذي هو مطلوب قبل أن تتمكن من المضي قدما في نقل متابِعيك من الحساب القديم إلى هذا الحساب. هذا الإجراء بحد ذاته هو <strong>غير مؤذي و قابل للعكس</strong>. <strong>تتم بداية تهجير الحساب من الحساب القديم</strong>.
remove: إلغاء ربط الكنية remove: إلغاء ربط الكنية
appearance: appearance:
advanced_settings: الإعدادات المتقدمة
animations_and_accessibility: الإتاحة والحركة animations_and_accessibility: الإتاحة والحركة
discovery: الاستكشاف discovery: الاستكشاف
localization: localization:
@ -1368,6 +1375,12 @@ ar:
hint_html: "<strong>توصية:</strong> لن نطلب منك ثانية كلمتك السرية في غضون الساعة اللاحقة." hint_html: "<strong>توصية:</strong> لن نطلب منك ثانية كلمتك السرية في غضون الساعة اللاحقة."
invalid_password: الكلمة السرية خاطئة invalid_password: الكلمة السرية خاطئة
prompt: أكِّد الكلمة السرية للمواصلة prompt: أكِّد الكلمة السرية للمواصلة
color_scheme:
auto: تلقائي
dark: داكن
light: فاتح
contrast:
auto: تلقائي
crypto: crypto:
errors: errors:
invalid_key: ليس بمفتاح Ed25519 أو Curve25519 صالح invalid_key: ليس بمفتاح Ed25519 أو Curve25519 صالح
@ -1733,6 +1746,9 @@ ar:
expires_at: تنتهي مدة صلاحيتها في expires_at: تنتهي مدة صلاحيتها في
uses: عدد الاستخدامات uses: عدد الاستخدامات
title: دعوة أشخاص title: دعوة أشخاص
link_preview:
potentially_sensitive_content:
hide_button: إخفاء
lists: lists:
errors: errors:
limit: لقد بلغت الحد الأقصى للقوائم limit: لقد بلغت الحد الأقصى للقوائم
@ -2052,6 +2068,9 @@ ar:
zero: "%{count} فيديوهات" zero: "%{count} فيديوهات"
boosted_from_html: تم إعادة ترقيته مِن %{acct_link} boosted_from_html: تم إعادة ترقيته مِن %{acct_link}
content_warning: 'تحذير عن المحتوى: %{warning}' content_warning: 'تحذير عن المحتوى: %{warning}'
content_warnings:
hide: إخفاء المنشور
show: إظهار المزيد
default_language: نفس لغة الواجهة default_language: نفس لغة الواجهة
disallowed_hashtags: disallowed_hashtags:
few: 'يحتوي على وسوم غير مسموح بها: %{tags}' few: 'يحتوي على وسوم غير مسموح بها: %{tags}'
@ -2128,11 +2147,6 @@ ar:
past_preamble_html: لقد غيرنا شروط خدمتنا منذ زيارتكم الأخيرة. نشجعكم على مراجعة الشروط المحدثة. past_preamble_html: لقد غيرنا شروط خدمتنا منذ زيارتكم الأخيرة. نشجعكم على مراجعة الشروط المحدثة.
review_link: مراجعة شروط الخدمة review_link: مراجعة شروط الخدمة
title: شروط خدمة النطاق %{domain} ستتغير title: شروط خدمة النطاق %{domain} ستتغير
themes:
contrast: ماستدون (تباين عالٍ)
default: ماستدون (داكن)
mastodon-light: ماستدون (فاتح)
system: تلقائي (استخدم سمة النظام)
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"
@ -2304,6 +2318,7 @@ ar:
error: حدثت مشكلة ما خلال حذف مفتاح أمانك. الرجاء المحاولة مرة أخرى. error: حدثت مشكلة ما خلال حذف مفتاح أمانك. الرجاء المحاولة مرة أخرى.
success: تم حذف مفتاح الأمان الخاص بك. success: تم حذف مفتاح الأمان الخاص بك.
invalid_credential: مفتاح الأمان غير صالح invalid_credential: مفتاح الأمان غير صالح
nickname: الكنية
nickname_hint: أدخل اسم مستعار لمفتاح الأمان الجديد الخاص بك nickname_hint: أدخل اسم مستعار لمفتاح الأمان الجديد الخاص بك
not_enabled: لم تقم بتفعيل WebAuthn بعد not_enabled: لم تقم بتفعيل WebAuthn بعد
not_supported: هذا المتصفح لا يدعم مفاتيح الأمان not_supported: هذا المتصفح لا يدعم مفاتيح الأمان

View File

@ -834,10 +834,6 @@ ast:
sensitive_content: Conteníu sensible sensitive_content: Conteníu sensible
tags: tags:
does_not_match_previous_name: nun concasa col nome anterior does_not_match_previous_name: nun concasa col nome anterior
themes:
contrast: Mastodon (contraste altu)
default: Mastodon (escuridá)
mastodon-light: Mastodon (claridá)
two_factor_authentication: two_factor_authentication:
enabled: L'autenticación en dos pasos ta activada enabled: L'autenticación en dos pasos ta activada
enabled_success: L'autenticación en dos pasos activóse correutamente enabled_success: L'autenticación en dos pasos activóse correutamente

View File

@ -1834,7 +1834,7 @@ be:
trillion: трлн trillion: трлн
otp_authentication: otp_authentication:
code_hint: Каб пацвердзіць, увядзіце код, згенераваны праграмай-аўтэнтыфікатарам code_hint: Каб пацвердзіць, увядзіце код, згенераваны праграмай-аўтэнтыфікатарам
description_html: Калі вы ўключыце <strong>двухфактарную аўтэнтыфікацыю</strong> праз праграму-аўтэнтыфікатар, то каб увайсці ва ўліковы запіс, вам трэба мець тэлефон, які будзе генераваць токены для ўводу. description_html: Калі вы ўключыце <strong>двухфактарную аўтэнтыфікацыю</strong> праз праграму-аўтэнтыфікатар, то для ўваходу ва ўліковы запіс вам трэба будзе мець тэлефон, які будзе генераваць токены для ўводу.
enable: Уключыць enable: Уключыць
instructions_html: "<strong>Скануйце гэты QR-код у Google Authenticator або ў аналагічнай TOTP-праграме на вашым тэлефоне</strong>. З гэтага моманту праграма будзе генераваць токены, якія вам трэба будзе ўводзіць пры ўваходзе ва ўліковы запіс." instructions_html: "<strong>Скануйце гэты QR-код у Google Authenticator або ў аналагічнай TOTP-праграме на вашым тэлефоне</strong>. З гэтага моманту праграма будзе генераваць токены, якія вам трэба будзе ўводзіць пры ўваходзе ва ўліковы запіс."
manual_instructions: 'Калі вы не можаце сканаваць QR-код і трэба ўвесці яго ўручную, вось ключ у форме тэксту:' manual_instructions: 'Калі вы не можаце сканаваць QR-код і трэба ўвесці яго ўручную, вось ключ у форме тэксту:'
@ -2109,10 +2109,7 @@ be:
review_link: Прачытаць умовы карыстання review_link: Прачытаць умовы карыстання
title: На %{domain} змяняюцца ўмовы карыстання title: На %{domain} змяняюцца ўмовы карыстання
themes: themes:
contrast: Mastodon (высокі кантраст) default: Mastodon
default: Mastodon (цёмная)
mastodon-light: Mastodon (светлая)
system: Аўтаматычна (выкарыстоўваць сістэмную тэму)
time: time:
formats: formats:
default: "%d.%m.%Y %H:%M" default: "%d.%m.%Y %H:%M"

View File

@ -1975,11 +1975,6 @@ bg:
past_preamble_html: Променихме условията на услугата ни от последното ви посещение. Насърчаваме ви да разгледате обновените условия. past_preamble_html: Променихме условията на услугата ни от последното ви посещение. Насърчаваме ви да разгледате обновените условия.
review_link: Разглеждане на условията на услугата review_link: Разглеждане на условията на услугата
title: Условията на услугата на %{domain} се променят title: Условията на услугата на %{domain} се променят
themes:
contrast: Mastodon (висок контраст)
default: Mastodon (тъмно)
mastodon-light: Mastodon (светло)
system: Самодейно (употреба на системната тема)
time: time:
formats: formats:
default: "%d %b, %Y, %H:%M" default: "%d %b, %Y, %H:%M"

View File

@ -867,11 +867,6 @@ br:
'7889238': 3 mizvezh '7889238': 3 mizvezh
terms_of_service: terms_of_service:
title: Divizoù implijout title: Divizoù implijout
themes:
contrast: Mastodon (Dargemm kreñv)
default: Mastodon (Teñval)
mastodon-light: Mastodon (Sklaer)
system: Emgefreek (implijout neuz ar reizhiad)
time: time:
formats: formats:
default: "%d a viz %b %Y, %H:%M" default: "%d a viz %b %Y, %H:%M"

View File

@ -1995,11 +1995,6 @@ ca:
past_preamble_html: Hem fet canvis a les condicions de servei des de la vostra darrera visita. Us recomanem que hi doneu un cop d'ull. past_preamble_html: Hem fet canvis a les condicions de servei des de la vostra darrera visita. Us recomanem que hi doneu un cop d'ull.
review_link: Revisió de les condicions de servei review_link: Revisió de les condicions de servei
title: Les condicions de servei de %{domain} han canviat title: Les condicions de servei de %{domain} han canviat
themes:
contrast: Mastodon (alt contrast)
default: Mastodon (fosc)
mastodon-light: Mastodon (clar)
system: Automàtic (utilitza el tema del sistema)
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"

View File

@ -939,10 +939,6 @@ ckb:
sensitive_content: ناوەڕۆکی هەستیار sensitive_content: ناوەڕۆکی هەستیار
tags: tags:
does_not_match_previous_name: لەگەڵ ناوی پێشوو یەک ناگرێتەوە does_not_match_previous_name: لەگەڵ ناوی پێشوو یەک ناگرێتەوە
themes:
contrast: ماستۆدۆن (کۆنتراستی بەرز)
default: ماستۆدۆن (ڕەش)
mastodon-light: ماستۆدۆن (کاڵ)
two_factor_authentication: two_factor_authentication:
add: زیادکردن add: زیادکردن
disable: لەکارخستنی 2FA disable: لەکارخستنی 2FA

View File

@ -950,10 +950,6 @@ co:
sensitive_content: Cuntenutu sensibile sensitive_content: Cuntenutu sensibile
tags: tags:
does_not_match_previous_name: ùn currisponde micca à l'anzianu nome does_not_match_previous_name: ùn currisponde micca à l'anzianu nome
themes:
contrast: Mastodon (Cuntrastu altu)
default: Mastodon (Scuru)
mastodon-light: Mastodon (Chjaru)
time: time:
formats: formats:
default: "%d %b %Y, %H:%M" default: "%d %b %Y, %H:%M"

View File

@ -2103,11 +2103,6 @@ cs:
past_preamble_html: Od vaší poslední návštěvy jsme změnili podmínky služby. Doporučujeme vám zkontrolovat aktualizované podmínky. past_preamble_html: Od vaší poslední návštěvy jsme změnili podmínky služby. Doporučujeme vám zkontrolovat aktualizované podmínky.
review_link: Zkontrolovat podmínky užívání služby review_link: Zkontrolovat podmínky užívání služby
title: Podmínky služby %{domain} se mění title: Podmínky služby %{domain} se mění
themes:
contrast: Mastodon (vysoký kontrast)
default: Mastodon (tmavý)
mastodon-light: Mastodon (světlý)
system: Automaticky (dle motivu systému)
time: time:
formats: formats:
default: "%d. %b %Y, %H:%M" default: "%d. %b %Y, %H:%M"

View File

@ -2189,11 +2189,6 @@ cy:
past_preamble_html: Rydym wedi newid ein telerau gwasanaeth ers eich ymweliad diwethaf. Rydym yn eich annog i ddarllen y telerau wedi'u diweddaru. past_preamble_html: Rydym wedi newid ein telerau gwasanaeth ers eich ymweliad diwethaf. Rydym yn eich annog i ddarllen y telerau wedi'u diweddaru.
review_link: Darllen y telerau gwasanaeth review_link: Darllen y telerau gwasanaeth
title: Mae telerau gwasanaeth %{domain} yn newid title: Mae telerau gwasanaeth %{domain} yn newid
themes:
contrast: Mastodon (Cyferbyniad uchel)
default: Mastodon (Tywyll)
mastodon-light: Mastodon (Golau)
system: Awtomatig (defnyddio thema system)
time: time:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"

View File

@ -2021,10 +2021,7 @@ da:
review_link: Gennemgå Tjenestevilkår review_link: Gennemgå Tjenestevilkår
title: Tjenestevilkårene for %{domain} ændres title: Tjenestevilkårene for %{domain} ændres
themes: themes:
contrast: Mastodon (høj kontrast) default: Mastodon
default: Mastodon (mørkt)
mastodon-light: Mastodon (lyst)
system: Automatisk (benyt systemtema)
time: time:
formats: formats:
default: "%d. %b %Y, %H:%M" default: "%d. %b %Y, %H:%M"

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