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

Merge upstream changes up to edeae945c0
shrike
Claire 2024-08-21 20:31:08 +02:00 committed by GitHub
commit 0cd60fdb82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 1078 additions and 606 deletions

View File

@ -281,8 +281,8 @@ GEM
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.1.0)
fugit (1.10.1)
et-orbi (~> 1, >= 1.2.7)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
fuubar (2.5.1)
rspec-core (~> 3.0)

View File

@ -437,12 +437,12 @@ export function unpinFail(status, error) {
};
}
function toggleReblogWithoutConfirmation(status, privacy) {
function toggleReblogWithoutConfirmation(status, visibility) {
return (dispatch) => {
if (status.get('reblogged')) {
dispatch(unreblog({ statusId: status.get('id') }));
} else {
dispatch(reblog({ statusId: status.get('id'), privacy }));
dispatch(reblog({ statusId: status.get('id'), visibility }));
}
};
}

View File

@ -11,6 +11,7 @@ import type {
} from 'flavours/glitch/api_types/notifications';
import { allNotificationTypes } from 'flavours/glitch/api_types/notifications';
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
import { usePendingItems } from 'flavours/glitch/initial_state';
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
@ -103,6 +104,28 @@ export const fetchNotificationsGap = createDataLoadingThunk(
},
);
export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotifications({
max_id: undefined,
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
since_id: usePendingItems
? getState().notificationGroups.groups.find(
(group) => group.type !== 'gap',
)?.page_max_id
: undefined,
});
},
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
dispatchAssociatedRecords(dispatch, notifications);
return { notifications };
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch, getState }) => {

View File

@ -10,7 +10,7 @@ import {
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { processNewNotificationForGroups, refreshStaleNotificationGroups } from './notification_groups';
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import {
@ -37,7 +37,7 @@ const randomUpTo = max =>
* @param {string} channelName
* @param {Object.<string, string>} params
* @param {Object} options
* @param {function(Function): Promise<void>} [options.fallback]
* @param {function(Function, Function): Promise<void>} [options.fallback]
* @param {function(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept]
* @returns {function(): void}
@ -52,11 +52,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
let pollingId;
/**
* @param {function(Function): Promise<void>} fallback
* @param {function(Function, Function): Promise<void>} fallback
*/
const useFallback = async fallback => {
await fallback(dispatch);
await fallback(dispatch, getState);
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
};
@ -139,10 +139,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/**
* @param {Function} dispatch
* @param {Function} getState
*/
async function refreshHomeTimelineAndNotification(dispatch) {
async function refreshHomeTimelineAndNotification(dispatch, getState) {
await dispatch(expandHomeTimeline({ maxId: undefined }));
await dispatch(expandNotifications({}));
// TODO: remove this once the groups feature replaces the previous one
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch (error) {
// TODO
}
} else {
await dispatch(expandNotifications({}));
}
await dispatch(fetchAnnouncements());
}

View File

@ -4,6 +4,7 @@ import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/
export const apiFetchNotifications = async (params?: {
exclude_types?: string[];
max_id?: string;
since_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET',

View File

@ -1,28 +1,23 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface Props {
resource: JSX.Element;
message: React.ReactNode;
label: React.ReactNode;
url: string;
className?: string;
}
export const TimelineHint: React.FC<Props> = ({ className, resource, url }) => (
export const TimelineHint: React.FC<Props> = ({
className,
message,
label,
url,
}) => (
<div className={classNames('timeline-hint', className)}>
<strong>
<FormattedMessage
id='timeline_hint.remote_resource_not_displayed'
defaultMessage='{resource} from other servers are not displayed.'
values={{ resource }}
/>
</strong>
<br />
<p>{message}</p>
<a href={url} target='_blank' rel='noopener noreferrer'>
<FormattedMessage
id='account.browse_more_on_origin_server'
defaultMessage='Browse more on the original profile'
/>
{label}
</a>
</div>
);

View File

@ -12,6 +12,7 @@ import ProfileColumnHeader from 'flavours/glitch/features/account/components/pro
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';
import { useAppSelector } from 'flavours/glitch/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
@ -57,12 +58,22 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
);
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.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class AccountTimeline extends ImmutablePureComponent {
@ -176,12 +187,12 @@ class AccountTimeline extends ImmutablePureComponent {
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column ref={this.setRef}>

View File

@ -12,6 +12,7 @@ import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
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';
import { useAppSelector } from 'flavours/glitch/store';
import {
lookupAccount,
@ -50,12 +51,22 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
);
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 {
@ -145,12 +156,12 @@ class Followers extends ImmutablePureComponent {
} 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 url={remoteUrl} />;
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column ref={this.setRef}>

View File

@ -12,6 +12,7 @@ import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
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';
import { useAppSelector } from 'flavours/glitch/store';
import {
lookupAccount,
@ -50,12 +51,22 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
);
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 {
@ -145,12 +156,12 @@ class Following extends ImmutablePureComponent {
} 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 url={remoteUrl} />;
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 url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column ref={this.setRef}>

View File

@ -0,0 +1,22 @@
import { Link } from 'react-router-dom';
import { useAppSelector } from 'flavours/glitch/store';
export const DisplayedName: React.FC<{
accountIds: string[];
}> = ({ accountIds }) => {
const lastAccountId = accountIds[0] ?? '0';
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
if (!account) return null;
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
};

View File

@ -1,51 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from 'flavours/glitch/store';
export const NamesList: React.FC<{
accountIds: string[];
total: number;
seeMoreHref?: string;
}> = ({ accountIds, total, seeMoreHref }) => {
const lastAccountId = accountIds[0] ?? '0';
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
if (!account) return null;
const displayedName = (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
if (total === 1) {
return displayedName;
}
if (seeMoreHref)
return (
<FormattedMessage
id='name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a>'
values={{
name: displayedName,
count: total - 1,
a: (chunks) => <Link to={seeMoreHref}>{chunks}</Link>,
}}
/>
);
return (
<FormattedMessage
id='name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}}'
values={{ name: displayedName, count: total - 1 }}
/>
);
};

View File

@ -6,13 +6,27 @@ import type { NotificationGroupAdminSignUp } from 'flavours/glitch/models/notifi
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.admin.sign_up'
defaultMessage='{name} signed up'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.admin.sign_up'
defaultMessage='{name} signed up'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.admin.sign_up.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} signed up'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationAdminSignUp: React.FC<{
notification: NotificationGroupAdminSignUp;

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import type { NotificationGroupFavourite } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favorited your status'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
if (total === 1)
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favorited your status'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.favourite.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationFavourite: React.FC<{
notification: NotificationGroupFavourite;

View File

@ -10,13 +10,27 @@ import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} followed you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
const FollowerCount: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((s) => s.accounts.get(accountId));

View File

@ -21,13 +21,27 @@ const messages = defineMessages({
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow_request.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} has requested to follow you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationFollowRequest: React.FC<{
notification: NotificationGroupFollowRequest;

View File

@ -12,11 +12,13 @@ import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp
import { useAppDispatch } from 'flavours/glitch/store';
import { AvatarGroup } from './avatar_group';
import { DisplayedName } from './displayed_name';
import { EmbeddedStatus } from './embedded_status';
import { NamesList } from './names_list';
export type LabelRenderer = (
values: Record<string, React.ReactNode>,
displayedName: JSX.Element,
total: number,
seeMoreHref?: string,
) => JSX.Element;
export const NotificationGroupWithStatus: React.FC<{
@ -50,15 +52,11 @@ export const NotificationGroupWithStatus: React.FC<{
const label = useMemo(
() =>
labelRenderer({
name: (
<NamesList
accountIds={accountIds}
total={count}
seeMoreHref={labelSeeMoreHref}
/>
),
}),
labelRenderer(
<DisplayedName accountIds={accountIds} />,
count,
labelSeeMoreHref,
),
[labelRenderer, accountIds, count, labelSeeMoreHref],
);

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import type { NotificationGroupReblog } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
if (total === 1)
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.reblog.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> boosted your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationReblog: React.FC<{
notification: NotificationGroupReblog;

View File

@ -6,11 +6,11 @@ import type { NotificationGroupStatus } from 'flavours/glitch/models/notificatio
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage
id='notification.status'
defaultMessage='{name} just posted'
values={values}
values={{ name: displayedName }}
/>
);

View File

@ -6,11 +6,11 @@ import type { NotificationGroupUpdate } from 'flavours/glitch/models/notificatio
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage
id='notification.update'
defaultMessage='{name} edited a post'
values={values}
values={{ name: displayedName }}
/>
);

View File

@ -18,7 +18,7 @@ import { Icon } from 'flavours/glitch/components/icon';
import Status from 'flavours/glitch/containers/status_container';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NamesList } from './names_list';
import { DisplayedName } from './displayed_name';
import type { LabelRenderer } from './notification_group_with_status';
export const NotificationWithStatus: React.FC<{
@ -43,10 +43,7 @@ export const NotificationWithStatus: React.FC<{
const dispatch = useAppDispatch();
const label = useMemo(
() =>
labelRenderer({
name: <NamesList accountIds={accountIds} total={count} />,
}),
() => labelRenderer(<DisplayedName accountIds={accountIds} />, count),
[labelRenderer, accountIds, count],
);

View File

@ -655,7 +655,14 @@ class Status extends ImmutablePureComponent {
const isIndexable = !status.getIn(['account', 'noindex']);
if (!isLocal) {
remoteHint = <TimelineHint className={classNames(!!descendants && 'timeline-hint--with-descendants')} url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
remoteHint = (
<TimelineHint
className={classNames(!!descendants && 'timeline-hint--with-descendants')}
url={status.get('url')}
message={<FormattedMessage id='hints.threads.replies_may_be_missing' defaultMessage='Replies from other servers may be missing.' />}
label={<FormattedMessage id='hints.threads.see_more' defaultMessage='See more replies on {domain}' values={{ domain: <strong>{status.getIn(['account', 'acct']).split('@')[1]}</strong> }} />}
/>
);
}
const handlers = {

View File

@ -20,12 +20,16 @@ import {
mountNotifications,
unmountNotifications,
refreshStaleNotificationGroups,
pollRecentNotifications,
} from 'flavours/glitch/actions/notification_groups';
import {
disconnectTimeline,
timelineDelete,
} from 'flavours/glitch/actions/timelines_typed';
import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications';
import type {
ApiNotificationJSON,
ApiNotificationGroupJSON,
} from 'flavours/glitch/api_types/notifications';
import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems } from 'flavours/glitch/initial_state';
import {
@ -296,6 +300,106 @@ function commitLastReadId(state: NotificationGroupsState) {
}
}
function fillNotificationsGap(
groups: NotificationGroupsState['groups'],
gap: NotificationGap,
notifications: ApiNotificationGroupJSON[],
): NotificationGroupsState['groups'] {
// find the gap in the existing notifications
const gapIndex = groups.findIndex(
(groupOrGap) =>
groupOrGap.type === 'gap' &&
groupOrGap.sinceId === gap.sinceId &&
groupOrGap.maxId === gap.maxId,
);
if (gapIndex < 0)
// We do not know where to insert, let's return
return groups;
// Filling a disconnection gap means we're getting historical data
// about groups we may know or may not know about.
// The notifications timeline is split in two by the gap, with
// group information newer than the gap, and group information older
// than the gap.
// Filling a gap should not touch anything before the gap, so any
// information on groups already appearing before the gap should be
// discarded, while any information on groups appearing after the gap
// can be updated and re-ordered.
const oldestPageNotification = notifications.at(-1)?.page_min_id;
// replace the gap with the notifications + a new gap
const newerGroupKeys = groups
.slice(0, gapIndex)
.filter(isNotificationGroup)
.map((group) => group.group_key);
const toInsert: NotificationGroupsState['groups'] = notifications
.map((json) => createNotificationGroupFromJSON(json))
.filter((notification) => !newerGroupKeys.includes(notification.group_key));
const apiGroupKeys = (toInsert as NotificationGroup[]).map(
(group) => group.group_key,
);
const sinceId = gap.sinceId;
if (
notifications.length > 0 &&
!(
oldestPageNotification &&
sinceId &&
compareId(oldestPageNotification, sinceId) <= 0
)
) {
// If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap
// Similarly, if we've fetched more than the gap's, this means we have completely filled it
toInsert.push({
type: 'gap',
maxId: notifications.at(-1)?.page_max_id,
sinceId,
} as NotificationGap);
}
// Remove older groups covered by the API
groups = groups.filter(
(groupOrGap) =>
groupOrGap.type !== 'gap' && !apiGroupKeys.includes(groupOrGap.group_key),
);
// Replace the gap with API results (+ the new gap if needed)
groups.splice(gapIndex, 1, ...toInsert);
// Finally, merge any adjacent gaps that could have been created by filtering
// groups earlier
mergeGaps(groups);
return groups;
}
// Ensure the groups list starts with a gap, mutating it to prepend one if needed
function ensureLeadingGap(
groups: NotificationGroupsState['groups'],
): NotificationGap {
if (groups[0]?.type === 'gap') {
// We're expecting new notifications, so discard the maxId if there is one
groups[0].maxId = undefined;
return groups[0];
} else {
const gap: NotificationGap = {
type: 'gap',
sinceId: groups[0]?.page_min_id,
};
groups.unshift(gap);
return gap;
}
}
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
initialState,
(builder) => {
@ -309,86 +413,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
updateLastReadId(state);
})
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
const { notifications } = action.payload;
// find the gap in the existing notifications
const gapIndex = state.groups.findIndex(
(groupOrGap) =>
groupOrGap.type === 'gap' &&
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
groupOrGap.maxId === action.meta.arg.gap.maxId,
state.groups = fillNotificationsGap(
state.groups,
action.meta.arg.gap,
action.payload.notifications,
);
state.isLoading = false;
if (gapIndex < 0)
// We do not know where to insert, let's return
return;
// Filling a disconnection gap means we're getting historical data
// about groups we may know or may not know about.
// The notifications timeline is split in two by the gap, with
// group information newer than the gap, and group information older
// than the gap.
// Filling a gap should not touch anything before the gap, so any
// information on groups already appearing before the gap should be
// discarded, while any information on groups appearing after the gap
// can be updated and re-ordered.
const oldestPageNotification = notifications.at(-1)?.page_min_id;
// replace the gap with the notifications + a new gap
const newerGroupKeys = state.groups
.slice(0, gapIndex)
.filter(isNotificationGroup)
.map((group) => group.group_key);
const toInsert: NotificationGroupsState['groups'] = notifications
.map((json) => createNotificationGroupFromJSON(json))
.filter(
(notification) => !newerGroupKeys.includes(notification.group_key),
updateLastReadId(state);
})
.addCase(pollRecentNotifications.fulfilled, (state, action) => {
if (usePendingItems) {
const gap = ensureLeadingGap(state.pendingGroups);
state.pendingGroups = fillNotificationsGap(
state.pendingGroups,
gap,
action.payload.notifications,
);
} else {
const gap = ensureLeadingGap(state.groups);
state.groups = fillNotificationsGap(
state.groups,
gap,
action.payload.notifications,
);
const apiGroupKeys = (toInsert as NotificationGroup[]).map(
(group) => group.group_key,
);
const sinceId = action.meta.arg.gap.sinceId;
if (
notifications.length > 0 &&
!(
oldestPageNotification &&
sinceId &&
compareId(oldestPageNotification, sinceId) <= 0
)
) {
// If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap
// Similarly, if we've fetched more than the gap's, this means we have completely filled it
toInsert.push({
type: 'gap',
maxId: notifications.at(-1)?.page_max_id,
sinceId,
} as NotificationGap);
}
// Remove older groups covered by the API
state.groups = state.groups.filter(
(groupOrGap) =>
groupOrGap.type !== 'gap' &&
!apiGroupKeys.includes(groupOrGap.group_key),
);
// Replace the gap with API results (+ the new gap if needed)
state.groups.splice(gapIndex, 1, ...toInsert);
// Finally, merge any adjacent gaps that could have been created by filtering
// groups earlier
mergeGaps(state.groups);
state.isLoading = false;
updateLastReadId(state);
trimNotifications(state);
})
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload;
@ -403,10 +457,11 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
})
.addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') {
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
state.groups.unshift({
const groups = usePendingItems ? state.pendingGroups : state.groups;
if (groups.length > 0 && groups[0]?.type !== 'gap') {
groups.unshift({
type: 'gap',
sinceId: state.groups[0]?.page_min_id,
sinceId: groups[0]?.page_min_id,
});
}
}
@ -453,12 +508,13 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}
}
}
trimNotifications(state);
});
// Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = [];
mergeGaps(state.groups);
trimNotifications(state);
})
.addCase(updateScrollPosition.fulfilled, (state, action) => {
state.scrolledToTop = action.payload.top;
@ -518,13 +574,21 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
},
)
.addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
isAnyOf(
fetchNotifications.pending,
fetchNotificationsGap.pending,
pollRecentNotifications.pending,
),
(state) => {
state.isLoading = true;
},
)
.addMatcher(
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected),
isAnyOf(
fetchNotifications.rejected,
fetchNotificationsGap.rejected,
pollRecentNotifications.rejected,
),
(state) => {
state.isLoading = false;
},

View File

@ -130,21 +130,11 @@
.older {
float: left;
padding-inline-start: 0;
.fa {
display: inline-block;
margin-inline-end: 5px;
}
}
.newer {
float: right;
padding-inline-start: 0;
.fa {
display: inline-block;
margin-inline-start: 5px;
}
}
.disabled {

View File

@ -122,10 +122,6 @@ $content-width: 840px;
overflow: hidden;
text-overflow: ellipsis;
i.fa {
margin-inline-end: 5px;
}
&:hover {
color: $primary-text-color;
transition: all 100ms linear;
@ -306,10 +302,6 @@ $content-width: 840px;
box-shadow: none;
}
.directory__tag .table-action-link .fa {
color: inherit;
}
.directory__tag h4 {
font-size: 18px;
font-weight: 700;

View File

@ -3073,10 +3073,6 @@ $ui-header-logo-wordmark-width: 99px;
padding-inline-end: 30px;
}
.search__icon .fa {
top: 15px;
}
.scrollable {
overflow: visible;
@ -4422,11 +4418,12 @@ a.status-card {
.timeline-hint {
text-align: center;
color: $darker-text-color;
padding: 15px;
color: $dark-text-color;
padding: 16px;
box-sizing: border-box;
width: 100%;
cursor: default;
font-size: 14px;
line-height: 21px;
strong {
font-weight: 500;
@ -4443,10 +4440,10 @@ a.status-card {
color: lighten($highlight-text-color, 4%);
}
}
}
.timeline-hint--with-descendants {
border-top: 1px solid var(--background-border-color);
&--with-descendants {
border-top: 1px solid var(--background-border-color);
}
}
.regeneration-indicator {
@ -6725,7 +6722,7 @@ a.status-card {
}
.boost-modal__container {
overflow-x: scroll;
overflow-y: auto;
padding: 10px;
.status {

View File

@ -113,10 +113,6 @@
flex: 1 1 auto;
}
.fa {
flex: 0 0 auto;
}
strong {
font-weight: 700;
}

View File

@ -931,10 +931,6 @@ code {
font-weight: 700;
}
}
.fa {
font-weight: 400;
}
}
}
}

View File

@ -96,14 +96,6 @@ body.rtl {
no-repeat left 8px center / auto 16px;
}
.fa-chevron-left::before {
content: '\F054';
}
.fa-chevron-right::before {
content: '\F053';
}
.dismissable-banner,
.warning-banner {
&__action {

View File

@ -142,11 +142,6 @@ a.table-action-link {
color: $highlight-text-color;
}
i.fa {
font-weight: 400;
margin-inline-end: 5px;
}
&:first-child {
padding-inline-start: 0;
}

View File

@ -163,11 +163,6 @@
&__message {
margin-bottom: 15px;
.fa {
margin-inline-end: 5px;
color: $darker-text-color;
}
}
&__card {
@ -354,9 +349,7 @@
width: 21px;
}
.fa {
font-size: 16px;
.icon {
&.active {
color: $highlight-text-color;
}

View File

@ -437,12 +437,12 @@ export function unpinFail(status, error) {
};
}
function toggleReblogWithoutConfirmation(status, privacy) {
function toggleReblogWithoutConfirmation(status, visibility) {
return (dispatch) => {
if (status.get('reblogged')) {
dispatch(unreblog({ statusId: status.get('id') }));
} else {
dispatch(reblog({ statusId: status.get('id'), privacy }));
dispatch(reblog({ statusId: status.get('id'), visibility }));
}
};
}

View File

@ -11,6 +11,7 @@ import type {
} from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import { usePendingItems } from 'mastodon/initial_state';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
@ -103,6 +104,28 @@ export const fetchNotificationsGap = createDataLoadingThunk(
},
);
export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => {
return apiFetchNotifications({
max_id: undefined,
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones
since_id: usePendingItems
? getState().notificationGroups.groups.find(
(group) => group.type !== 'gap',
)?.page_max_id
: undefined,
});
},
({ notifications, accounts, statuses }, { dispatch }) => {
dispatch(importFetchedAccounts(accounts));
dispatch(importFetchedStatuses(statuses));
dispatchAssociatedRecords(dispatch, notifications);
return { notifications };
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch, getState }) => {

View File

@ -10,7 +10,7 @@ import {
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { processNewNotificationForGroups, refreshStaleNotificationGroups } from './notification_groups';
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import {
@ -37,7 +37,7 @@ const randomUpTo = max =>
* @param {string} channelName
* @param {Object.<string, string>} params
* @param {Object} options
* @param {function(Function): Promise<void>} [options.fallback]
* @param {function(Function, Function): Promise<void>} [options.fallback]
* @param {function(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept]
* @returns {function(): void}
@ -52,11 +52,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
let pollingId;
/**
* @param {function(Function): Promise<void>} fallback
* @param {function(Function, Function): Promise<void>} fallback
*/
const useFallback = async fallback => {
await fallback(dispatch);
await fallback(dispatch, getState);
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
};
@ -139,10 +139,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/**
* @param {Function} dispatch
* @param {Function} getState
*/
async function refreshHomeTimelineAndNotification(dispatch) {
async function refreshHomeTimelineAndNotification(dispatch, getState) {
await dispatch(expandHomeTimeline({ maxId: undefined }));
await dispatch(expandNotifications({}));
// TODO: remove this once the groups feature replaces the previous one
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
// TODO: polling for merged notifications
try {
await dispatch(pollRecentGroupNotifications());
} catch (error) {
// TODO
}
} else {
await dispatch(expandNotifications({}));
}
await dispatch(fetchAnnouncements());
}

View File

@ -4,6 +4,7 @@ import type { ApiNotificationGroupsResultJSON } from 'mastodon/api_types/notific
export const apiFetchNotifications = async (params?: {
exclude_types?: string[];
max_id?: string;
since_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET',

View File

@ -1,28 +1,23 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface Props {
resource: JSX.Element;
message: React.ReactNode;
label: React.ReactNode;
url: string;
className?: string;
}
export const TimelineHint: React.FC<Props> = ({ className, resource, url }) => (
export const TimelineHint: React.FC<Props> = ({
className,
message,
label,
url,
}) => (
<div className={classNames('timeline-hint', className)}>
<strong>
<FormattedMessage
id='timeline_hint.remote_resource_not_displayed'
defaultMessage='{resource} from other servers are not displayed.'
values={{ resource }}
/>
</strong>
<br />
<p>{message}</p>
<a href={url} target='_blank' rel='noopener noreferrer'>
<FormattedMessage
id='account.browse_more_on_origin_server'
defaultMessage='Browse more on the original profile'
/>
{label}
</a>
</div>
);

View File

@ -12,6 +12,7 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err
import { me } from 'mastodon/initial_state';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors';
import { useAppSelector } from 'mastodon/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
@ -59,12 +60,22 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
);
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.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
/>
);
};
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
};
class AccountTimeline extends ImmutablePureComponent {
@ -175,12 +186,12 @@ class AccountTimeline extends ImmutablePureComponent {
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>

View File

@ -12,6 +12,7 @@ import { TimelineHint } from 'mastodon/components/timeline_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors';
import { useAppSelector } from 'mastodon/store';
import {
lookupAccount,
@ -51,12 +52,22 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
);
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 {
@ -141,12 +152,12 @@ class Followers extends ImmutablePureComponent {
} 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 url={remoteUrl} />;
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>

View File

@ -12,6 +12,7 @@ import { TimelineHint } from 'mastodon/components/timeline_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors';
import { useAppSelector } from 'mastodon/store';
import {
lookupAccount,
@ -51,12 +52,22 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
);
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 {
@ -141,12 +152,12 @@ class Following extends ImmutablePureComponent {
} 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 url={remoteUrl} />;
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 url={remoteUrl} /> : null;
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
return (
<Column>

View File

@ -0,0 +1,22 @@
import { Link } from 'react-router-dom';
import { useAppSelector } from 'mastodon/store';
export const DisplayedName: React.FC<{
accountIds: string[];
}> = ({ accountIds }) => {
const lastAccountId = accountIds[0] ?? '0';
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
if (!account) return null;
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
};

View File

@ -1,51 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from 'mastodon/store';
export const NamesList: React.FC<{
accountIds: string[];
total: number;
seeMoreHref?: string;
}> = ({ accountIds, total, seeMoreHref }) => {
const lastAccountId = accountIds[0] ?? '0';
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
if (!account) return null;
const displayedName = (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
</Link>
);
if (total === 1) {
return displayedName;
}
if (seeMoreHref)
return (
<FormattedMessage
id='name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a>'
values={{
name: displayedName,
count: total - 1,
a: (chunks) => <Link to={seeMoreHref}>{chunks}</Link>,
}}
/>
);
return (
<FormattedMessage
id='name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}}'
values={{ name: displayedName, count: total - 1 }}
/>
);
};

View File

@ -6,13 +6,27 @@ import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.admin.sign_up'
defaultMessage='{name} signed up'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.admin.sign_up'
defaultMessage='{name} signed up'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.admin.sign_up.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} signed up'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationAdminSignUp: React.FC<{
notification: NotificationGroupAdminSignUp;

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import type { NotificationGroupFavourite } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favorited your status'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
if (total === 1)
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favorited your status'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.favourite.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationFavourite: React.FC<{
notification: NotificationGroupFavourite;

View File

@ -10,13 +10,27 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.follow'
defaultMessage='{name} followed you'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} followed you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
const FollowerCount: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((s) => s.accounts.get(accountId));

View File

@ -21,13 +21,27 @@ const messages = defineMessages({
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
});
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total) => {
if (total === 1)
return (
<FormattedMessage
id='notification.follow_request'
defaultMessage='{name} has requested to follow you'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.follow_request.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} has requested to follow you'
values={{
name: displayedName,
count: total - 1,
}}
/>
);
};
export const NotificationFollowRequest: React.FC<{
notification: NotificationGroupFollowRequest;

View File

@ -12,11 +12,13 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useAppDispatch } from 'mastodon/store';
import { AvatarGroup } from './avatar_group';
import { DisplayedName } from './displayed_name';
import { EmbeddedStatus } from './embedded_status';
import { NamesList } from './names_list';
export type LabelRenderer = (
values: Record<string, React.ReactNode>,
displayedName: JSX.Element,
total: number,
seeMoreHref?: string,
) => JSX.Element;
export const NotificationGroupWithStatus: React.FC<{
@ -50,15 +52,11 @@ export const NotificationGroupWithStatus: React.FC<{
const label = useMemo(
() =>
labelRenderer({
name: (
<NamesList
accountIds={accountIds}
total={count}
seeMoreHref={labelSeeMoreHref}
/>
),
}),
labelRenderer(
<DisplayedName accountIds={accountIds} />,
count,
labelSeeMoreHref,
),
[labelRenderer, accountIds, count, labelSeeMoreHref],
);

View File

@ -1,5 +1,7 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
@ -7,13 +9,29 @@ import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={values}
/>
);
const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
if (total === 1)
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name: displayedName }}
/>
);
return (
<FormattedMessage
id='notification.reblog.name_and_others_with_link'
defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> boosted your post'
values={{
name: displayedName,
count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}}
/>
);
};
export const NotificationReblog: React.FC<{
notification: NotificationGroupReblog;

View File

@ -6,11 +6,11 @@ import type { NotificationGroupStatus } from 'mastodon/models/notification_group
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage
id='notification.status'
defaultMessage='{name} just posted'
values={values}
values={{ name: displayedName }}
/>
);

View File

@ -6,11 +6,11 @@ import type { NotificationGroupUpdate } from 'mastodon/models/notification_group
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage
id='notification.update'
defaultMessage='{name} edited a post'
values={values}
values={{ name: displayedName }}
/>
);

View File

@ -15,7 +15,7 @@ import { Icon } from 'mastodon/components/icon';
import Status from 'mastodon/containers/status_container';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { NamesList } from './names_list';
import { DisplayedName } from './displayed_name';
import type { LabelRenderer } from './notification_group_with_status';
export const NotificationWithStatus: React.FC<{
@ -40,10 +40,7 @@ export const NotificationWithStatus: React.FC<{
const dispatch = useAppDispatch();
const label = useMemo(
() =>
labelRenderer({
name: <NamesList accountIds={accountIds} total={count} />,
}),
() => labelRenderer(<DisplayedName accountIds={accountIds} />, count),
[labelRenderer, accountIds, count],
);

View File

@ -629,7 +629,14 @@ class Status extends ImmutablePureComponent {
const isIndexable = !status.getIn(['account', 'noindex']);
if (!isLocal) {
remoteHint = <TimelineHint className={classNames(!!descendants && 'timeline-hint--with-descendants')} url={status.get('url')} resource={<FormattedMessage id='timeline_hint.resources.replies' defaultMessage='Some replies' />} />;
remoteHint = (
<TimelineHint
className={classNames(!!descendants && 'timeline-hint--with-descendants')}
url={status.get('url')}
message={<FormattedMessage id='hints.threads.replies_may_be_missing' defaultMessage='Replies from other servers may be missing.' />}
label={<FormattedMessage id='hints.threads.see_more' defaultMessage='See more replies on {domain}' values={{ domain: <strong>{status.getIn(['account', 'acct']).split('@')[1]}</strong> }} />}
/>
);
}
const handlers = {

View File

@ -518,7 +518,17 @@
"notification.status": "{name} hat gerade etwas gepostet",
"notification.update": "{name} bearbeitete einen Beitrag",
"notification_requests.accept": "Genehmigen",
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage genehmigen …} other {# Anfragen genehmigen …}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Anfrage genehmigen} other {Anfragen genehmigen}}",
"notification_requests.confirm_accept_multiple.message": "Du bist dabei, {{count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} zu genehmigen. Möchtest du wirklich fortfahren?",
"notification_requests.confirm_accept_multiple.title": "Benachrichtigungsanfragen genehmigen?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Anfrage ablehnen} other {Anfragen ablehnen}}",
"notification_requests.confirm_dismiss_multiple.message": "Du bist dabei, {count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} abzulehnen. Du wirst nicht mehr ohne Weiteres auf {count, plural, one {sie} other {sie}} zugreifen können. Möchtest du wirklich fortfahren?",
"notification_requests.confirm_dismiss_multiple.title": "Benachrichtigungsanfragen ablehnen?",
"notification_requests.dismiss": "Ablehnen",
"notification_requests.dismiss_multiple": "{count, plural, one {# Anfrage ablehnen …} other {# Anfragen ablehnen …}}",
"notification_requests.edit_selection": "Bearbeiten",
"notification_requests.exit_selection": "Fertig",
"notification_requests.explainer_for_limited_account": "Benachrichtigungen von diesem Konto wurden gefiltert, weil es durch Moderator*innen eingeschränkt wurde.",
"notification_requests.explainer_for_limited_remote_account": "Benachrichtigungen von diesem Konto wurden gefiltert, weil deren Konto oder Server durch Moderator*innen eingeschränkt wurde.",
"notification_requests.maximize": "Maximieren",

View File

@ -19,7 +19,6 @@
"account.block_domain": "Block domain {domain}",
"account.block_short": "Block",
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow",
"account.copy": "Copy link to profile",
"account.direct": "Privately mention @{name}",
@ -349,6 +348,14 @@
"hashtag.follow": "Follow hashtag",
"hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
"hints.profiles.follows_may_be_missing": "Follows for this profile may be missing.",
"hints.profiles.posts_may_be_missing": "Some posts from this profile may be missing.",
"hints.profiles.see_more_followers": "See more followers on {domain}",
"hints.profiles.see_more_follows": "See more follows on {domain}",
"hints.profiles.see_more_posts": "See more posts on {domain}",
"hints.threads.replies_may_be_missing": "Replies from other servers may be missing.",
"hints.threads.see_more": "See more replies on {domain}",
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
"home.hide_announcements": "Hide announcements",
@ -456,8 +463,6 @@
"mute_modal.title": "Mute user?",
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
"name_and_others": "{name} and {count, plural, one {# other} other {# others}}",
"name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a>",
"navigation_bar.about": "About",
"navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.blocks": "Blocked users",
@ -490,9 +495,13 @@
"notification.admin.report_statuses": "{name} reported {target} for {category}",
"notification.admin.report_statuses_other": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.admin.sign_up.name_and_others": "{name} and {count, plural, one {# other} other {# others}} signed up",
"notification.favourite": "{name} favorited your post",
"notification.favourite.name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a> favorited your post",
"notification.follow": "{name} followed you",
"notification.follow.name_and_others": "{name} and {count, plural, one {# other} other {# others}} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.follow_request.name_and_others": "{name} and {count, plural, one {# other} other {# others}} has requested to follow you",
"notification.label.mention": "Mention",
"notification.label.private_mention": "Private mention",
"notification.label.private_reply": "Private reply",
@ -510,6 +519,7 @@
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you voted in has ended",
"notification.reblog": "{name} boosted your post",
"notification.reblog.name_and_others_with_link": "{name} and <a>{count, plural, one {# other} other {# others}}</a> boosted your post",
"notification.relationships_severance_event": "Lost connections with {name}",
"notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.",
"notification.relationships_severance_event.domain_block": "An admin from {from} has blocked {target}, including {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.",
@ -826,11 +836,6 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.replies": "Some replies",
"timeline_hint.resources.statuses": "Older posts",
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} acaba de enviar un mensaje",
"notification.update": "{name} editó un mensaje",
"notification_requests.accept": "Aceptar",
"notification_requests.accept_multiple": "{count, plural, one {Aceptar # solicitud…} other {Aceptar # solicitudes…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Aceptar solicitud} other {Aceptar solicitudes}}",
"notification_requests.confirm_accept_multiple.message": "Estás a punto de aceptar {count, plural, one {una solicitud} other {# solicitudes}}. ¿Estás seguro de que querés continuar?",
"notification_requests.confirm_accept_multiple.title": "¿Aceptar solicitudes de notificación?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Descartar solicitud} other {Descartar solicitudes}}",
"notification_requests.confirm_dismiss_multiple.message": "Estás a punto de descartar {count, plural, one {una solicitud} other {# solicitudes}}. No vas a poder acceder fácilmente a {count, plural, one {ella} other {ellas}} de nuevo. ¿Estás seguro de que querés continuar?",
"notification_requests.confirm_dismiss_multiple.title": "¿Descartar solicitudes de notificación?",
"notification_requests.dismiss": "Descartar",
"notification_requests.dismiss_multiple": "{count, plural, one {Descartar # solicitud…} other {Descartar # solicitudes…}}",
"notification_requests.edit_selection": "Editar",
"notification_requests.exit_selection": "Listo",
"notification_requests.explainer_for_limited_account": "Las notificaciones de esta cuenta fueron filtradas porque la misma fue limitada por un moderador.",
"notification_requests.explainer_for_limited_remote_account": "Las notificaciones de esta cuenta fueron filtradas porque la cuenta o su servidor fueron limitados por un moderador.",
"notification_requests.maximize": "Maximizar",

View File

@ -518,6 +518,8 @@
"notification.status": "{name} acaba de publicar",
"notification.update": "{name} editó una publicación",
"notification_requests.accept": "Aceptar",
"notification_requests.accept_multiple": "{count, plural, one {Aceptar # solicitud…} other {Aceptar # solicitudes…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Aceptar solicitud} other {Aceptar solicitudes}}",
"notification_requests.dismiss": "Descartar",
"notification_requests.explainer_for_limited_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta ha sido limitada por un moderador.",
"notification_requests.explainer_for_limited_remote_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta o su servidor ha sido limitada por un moderador.",

View File

@ -518,6 +518,8 @@
"notification.status": "{name} acaba de publicar",
"notification.update": "{name} editó una publicación",
"notification_requests.accept": "Aceptar",
"notification_requests.accept_multiple": "{count, plural, one {Aceptar # solicitud…} other {Aceptar # solicitudes…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Aceptar solicitud} other {Aceptar solicitudes}}",
"notification_requests.dismiss": "Descartar",
"notification_requests.explainer_for_limited_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta ha sido limitada por un moderador.",
"notification_requests.explainer_for_limited_remote_account": "Las notificaciones de esta cuenta han sido filtradas porque la cuenta o su servidor ha sido limitada por un moderador.",

View File

@ -477,7 +477,7 @@
"navigation_bar.logout": "Kirjaudu ulos",
"navigation_bar.mutes": "Mykistetyt käyttäjät",
"navigation_bar.opened_in_classic_interface": "Julkaisut, profiilit ja tietyt muut sivut avautuvat oletuksena perinteiseen selainkäyttöliittymään.",
"navigation_bar.personal": "Henkilökohtainen",
"navigation_bar.personal": "Henkilökohtaiset",
"navigation_bar.pins": "Kiinnitetyt julkaisut",
"navigation_bar.preferences": "Asetukset",
"navigation_bar.public_timeline": "Yleinen aikajana",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} hevur júst postað",
"notification.update": "{name} rættaði ein post",
"notification_requests.accept": "Góðtak",
"notification_requests.accept_multiple": "{count, plural, one {Góðtak # umbøn…} other {Góðtak # umbønir…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Góðtak umbøn} other {Góðtak umbønir}}",
"notification_requests.confirm_accept_multiple.message": "Tú er í ferð við at góðtaka {count, plural, one {eina fráboðanarumbøn} other {# fráboðanarumbønir}}. Er tú vís/ur í, at tú vil halda fram?",
"notification_requests.confirm_accept_multiple.title": "Góðtak fráboðanarumbønir?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Avvís umbøn} other {Avvís umbønir}}",
"notification_requests.confirm_dismiss_multiple.message": "Tú er í ferð við at avvísa {count, plural, one {eina fráboðanarumbøn} other {# fráboðanarumbønir}}. Tað verður ikki lætt hjá tær at fáa fatur á {count, plural, one {henni} other {teimum}} aftur. Er tú vís/ur í at tú vil halda fram?",
"notification_requests.confirm_dismiss_multiple.title": "Avvís fráboðanarumbønir?",
"notification_requests.dismiss": "Avvís",
"notification_requests.dismiss_multiple": "{count, plural, one {Avvís # umbøn…} other {Avvís # umbønir…}}",
"notification_requests.edit_selection": "Rætta",
"notification_requests.exit_selection": "Liðugt",
"notification_requests.explainer_for_limited_account": "Fráboðanir frá hesi kontuni eru filtreraðar burtur, tí kontan er avmarkað av einum umsjónarfólki.",
"notification_requests.explainer_for_limited_remote_account": "Fráboðanir frá hesi kontuni eru filtreraðar burtur, tí kontan ella ambætarin hjá kontuni eru avmarkaði av einum umsjónarfólki.",
"notification_requests.maximize": "Mesta",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} publicou",
"notification.update": "{name} editou unha publicación",
"notification_requests.accept": "Aceptar",
"notification_requests.accept_multiple": "{count, plural, one {Aceptar # solicitude…} other {Aceptar # solicitudes…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Aceptar solicitude} other {Aceptar solicitudes}}",
"notification_requests.confirm_accept_multiple.message": "Vas aceptar {count, plural, one {unha solicitude de notificación} other {# solicitudes de notificación}}. Tes certeza de querer aceptar?",
"notification_requests.confirm_accept_multiple.title": "Aceptar solicitudes de notificación?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Rexeitar solicitude} other {Rexeitar solicitudes}}",
"notification_requests.confirm_dismiss_multiple.message": "Vas rexeitar {count, plural, one {unha solicitude de notificación} other {# solicitudes de notificación}}. Non poderás volver acceder fácilmente a {count, plural, one {ela} other {elas}}. Tes certeza de querer rexeitar?",
"notification_requests.confirm_dismiss_multiple.title": "Rexeitar solicitudes de notificación?",
"notification_requests.dismiss": "Desbotar",
"notification_requests.dismiss_multiple": "{count, plural, one {Rexeitar # solicitude…} other {Rexeitar # solicitudes…}}",
"notification_requests.edit_selection": "Editar",
"notification_requests.exit_selection": "Feito",
"notification_requests.explainer_for_limited_account": "Filtráronse as notificacións desta conta porque a conta ten limitacións impostas pola moderación.",
"notification_requests.explainer_for_limited_remote_account": "Filtráronse as notificacións desta conta porque a conta ou o seu servidor teñen limitacións impostas pola moderación.",
"notification_requests.maximize": "Maximizar",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} bejegyzést tett közzé",
"notification.update": "{name} szerkesztett egy bejegyzést",
"notification_requests.accept": "Elfogadás",
"notification_requests.accept_multiple": "{count, plural, one {# kérés elfogadása…} other {# kérés elfogadása…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Kérés elfogadása} other {Kérések elfogadása}}",
"notification_requests.confirm_accept_multiple.message": "Elfogadni készülsz {count, plural, one {egy értesítési kérést} other {# értesítési kérést}}. Biztosan folytatod?",
"notification_requests.confirm_accept_multiple.title": "Értesítési kérések elfogadása?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Kérés elvetése} other {Kérések elvetése}}",
"notification_requests.confirm_dismiss_multiple.message": "{count, plural, one {Egy értesítési kérés} other {# értesítési kérés}} elvetésére készülsz. Többé nem fogsz {count, plural, one {hozzáférni} other {hozzájuk férni}}. Biztosan folytatod?",
"notification_requests.confirm_dismiss_multiple.title": "Értesítési kérések elvetése?",
"notification_requests.dismiss": "Elvetés",
"notification_requests.dismiss_multiple": "{count, plural, one {# kérés elvetése…} other {# kérés elvetése…}}",
"notification_requests.edit_selection": "Szerkesztés",
"notification_requests.exit_selection": "Kész",
"notification_requests.explainer_for_limited_account": "Az ettől a fióktól származó értesítéseket kiszűrték, mert a fiókot egy moderátor korlátozta.",
"notification_requests.explainer_for_limited_remote_account": "Az ettől a fióktól származó értesítéseket kiszűrték, mert a fiókot vagy annak kiszolgálóját egy moderátor korlátozta.",
"notification_requests.maximize": "Maximalizálás",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} sendi inn rétt í þessu",
"notification.update": "{name} breytti færslu",
"notification_requests.accept": "Samþykkja",
"notification_requests.accept_multiple": "{count, plural, one {Samþykkja # beiðni…} other {Samþykkja # beiðnir…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Samþykkja beiðni} other {Samþykkja beiðnir}}",
"notification_requests.confirm_accept_multiple.message": "Þú ert að fara að samþykkja {count, plural, one {eina beiðni um tilkynningar} other {# beiðnir um tilkynningar}}. Ertu viss um að þú viljir halda áfram?",
"notification_requests.confirm_accept_multiple.title": "Samþykkja beiðnir um tilkynningar?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Afgreiða beiðni} other {Afgreiða beiðnir}}",
"notification_requests.confirm_dismiss_multiple.message": "Þú ert að fara að hunsa {count, plural, one {eina beiðni um tilkynningar} other {# beiðnir um tilkynningar}}. Þú munt ekki eiga auðvelt með að skoða {count, plural, one {hana} other {þær}} aftur síðar. Ertu viss um að þú viljir halda áfram?",
"notification_requests.confirm_dismiss_multiple.title": "Hunsa beiðnir um tilkynningar?",
"notification_requests.dismiss": "Afgreiða",
"notification_requests.dismiss_multiple": "{count, plural, one {Afgreiða # beiðni…} other {Afgreiða # beiðnir…}}",
"notification_requests.edit_selection": "Breyta",
"notification_requests.exit_selection": "Lokið",
"notification_requests.explainer_for_limited_account": "Tilkynningar frá þessum notanda hafa verið síaðar þar sem aðgangur hans hefur verið takmarkaður af umsjónarmanni.",
"notification_requests.explainer_for_limited_remote_account": "Tilkynningar frá þessum notanda hafa verið síaðar þar sem aðgangurinn eða netþjónn hans hefur verið takmarkaður af umsjónarmanni.",
"notification_requests.maximize": "Hámarka",

View File

@ -11,6 +11,7 @@
"about.not_available": "この情報はこのサーバーでは利用できません。",
"about.powered_by": "{mastodon}による分散型ソーシャルメディア",
"about.rules": "サーバーのルール",
"account.account_note_header": "自分用メモ",
"account.add_or_remove_from_list": "リストから追加または外す",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
@ -533,14 +534,23 @@
"notifications.permission_denied": "ブラウザの通知が拒否されているためデスクトップ通知は利用できません",
"notifications.permission_denied_alert": "ブラウザの通知が拒否されているためデスクトップ通知を有効にできません",
"notifications.permission_required": "必要な権限が付与されていないため、デスクトップ通知は利用できません。",
"notifications.policy.filter_new_accounts.hint": "作成から{days, plural, other {#日}}以内のアカウントからの通知がブロックされます",
"notifications.policy.filter_new_accounts_title": "新しいアカウントからの通知をブロックする",
"notifications.policy.filter_not_followers_hint": "フォローされていても、フォローから{days, plural, other {#日}}経っていない場合はブロックされます",
"notifications.policy.filter_not_followers_title": "フォローされていないアカウントからの通知をブロックする",
"notifications.policy.filter_not_following_hint": "手動で通知を受け入れたアカウントはブロックされません",
"notifications.policy.filter_not_following_title": "フォローしていないアカウントからの通知をブロックする",
"notifications.policy.filter_private_mentions_hint": "あなたがメンションした相手からの返信、およびフォローしているアカウントからの返信以外がブロックされます",
"notifications.policy.filter_private_mentions_title": "外部からの非公開の返信をブロックする",
"notifications.policy.accept": "受入れ",
"notifications.policy.accept_hint": "通知を表示します",
"notifications.policy.drop": "無視",
"notifications.policy.drop_hint": "通知を破棄します。再表示はできません。",
"notifications.policy.filter": "保留",
"notifications.policy.filter_hint": "「保留中の通知」に止め置きます",
"notifications.policy.filter_limited_accounts_hint": "モデレーターにより制限されたアカウントから送られる通知が対象です",
"notifications.policy.filter_limited_accounts_title": "モデレーションされたアカウントからの通知",
"notifications.policy.filter_new_accounts.hint": "作成から{days, plural, other {#日}}以内のアカウントが対象です",
"notifications.policy.filter_new_accounts_title": "新しいアカウントからの通知",
"notifications.policy.filter_not_followers_hint": "フォローされていても、フォローから{days, plural, other {#日}}経っていない場合は対象になります",
"notifications.policy.filter_not_followers_title": "フォローされていないアカウントからの通知",
"notifications.policy.filter_not_following_hint": "手動で通知を受け入れたアカウントは対象外です",
"notifications.policy.filter_not_following_title": "フォローしていないアカウントからの通知",
"notifications.policy.filter_private_mentions_hint": "あなたがメンションした相手からの返信、およびフォローしているアカウントからの返信は対象外です",
"notifications.policy.filter_private_mentions_title": "外部からの非公開の返信",
"notifications.policy.title": "通知のフィルタリング",
"notifications_permission_banner.enable": "デスクトップ通知を有効にする",
"notifications_permission_banner.how_to_control": "Mastodonを閉じている間でも通知を受信するにはデスクトップ通知を有効にしてください。有効にすると上の {icon} ボタンから通知の内容を細かくカスタマイズできます。",
"notifications_permission_banner.title": "お見逃しなく",
@ -781,6 +791,7 @@
"timeline_hint.remote_resource_not_displayed": "他のサーバーの{resource}は表示されません。",
"timeline_hint.resources.followers": "フォロワー",
"timeline_hint.resources.follows": "フォロー",
"timeline_hint.resources.replies": "返信の一部",
"timeline_hint.resources.statuses": "以前の投稿",
"trends.counter_by_accounts": "過去{days, plural, one {{days}日} other {{days}日}}に{count, plural, one {{counter}人} other {{counter} 人}}",
"trends.trending_now": "トレンドタグ",

View File

@ -348,7 +348,7 @@
"hashtag.counter_by_uses_today": "오늘 {count, plural, other {{counter} 개의 게시물}}",
"hashtag.follow": "팔로우",
"hashtag.unfollow": "팔로우 해제",
"hashtags.and_other": "…그리고 {count, plural,other {#개 더}}",
"hashtags.and_other": "…그리고 {count, plural,other {# 개 더}}",
"home.column_settings.show_reblogs": "부스트 표시",
"home.column_settings.show_replies": "답글 표시",
"home.hide_announcements": "공지사항 숨기기",
@ -359,6 +359,8 @@
"ignore_notifications_modal.disclaimer": "마스토돈은 당신이 그들의 알림을 무시했다는 걸 알려줄 수 없습니다. 알림을 무시한다고 해서 메시지가 오지 않는 것은 아닙니다.",
"ignore_notifications_modal.filter_instead": "대신 필터로 거르기",
"ignore_notifications_modal.filter_to_act_users": "여전히 사용자를 수락, 거절, 신고할 수 있습니다",
"ignore_notifications_modal.filter_to_avoid_confusion": "필터링은 혼란을 예방하는데 도움이 될 수 있습니다",
"ignore_notifications_modal.filter_to_review_separately": "걸러진 알림들을 개별적으로 검토할 수 있습니다",
"ignore_notifications_modal.ignore": "알림 무시",
"ignore_notifications_modal.limited_accounts_title": "중재된 계정의 알림을 무시할까요?",
"ignore_notifications_modal.new_accounts_title": "새 계정의 알림을 무시할까요?",
@ -441,7 +443,7 @@
"lists.replies_policy.title": "답글 표시:",
"lists.search": "팔로우 중인 사람들 중에서 찾기",
"lists.subheading": "리스트",
"load_pending": "{count}개의 새 항목",
"load_pending": "{count, plural, other {#}} 개의 새 항목",
"loading_indicator.label": "불러오는 중...",
"media_gallery.toggle_visible": "이미지 숨기기",
"moved_to_account_banner.text": "당신의 계정 {disabledAccount}는 {movedToAccount}로 이동하였기 때문에 현재 비활성화 상태입니다.",
@ -516,7 +518,17 @@
"notification.status": "{name} 님이 방금 게시물을 올렸습니다",
"notification.update": "{name} 님이 게시물을 수정했습니다",
"notification_requests.accept": "수락",
"notification_requests.accept_multiple": "{count, plural, other {#}} 개의 요청 수락하기",
"notification_requests.confirm_accept_multiple.button": "{count, plural, other {}}요청 수락하기",
"notification_requests.confirm_accept_multiple.message": "{count, plural, other {#}} 개의 요청을 수락하려고 합니다. 계속 진행할까요?",
"notification_requests.confirm_accept_multiple.title": "알림 요청을 수락할까요?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {요청 지우기}}",
"notification_requests.confirm_dismiss_multiple.message": "{count, plural, other {# 개의 요청}}을 지우려고 합니다. {count, plural, other {}}다시 접근하기 어렵습니다. 계속할까요?",
"notification_requests.confirm_dismiss_multiple.title": "알림 요청을 지울까요?",
"notification_requests.dismiss": "지우기",
"notification_requests.dismiss_multiple": "{count, plural, other {# 개의 요청 지우기}}",
"notification_requests.edit_selection": "편집",
"notification_requests.exit_selection": "완료",
"notification_requests.explainer_for_limited_account": "이 계정은 중재자에 의해 제한되었기 때문에 이 계정의 알림은 걸러졌습니다.",
"notification_requests.explainer_for_limited_remote_account": "이 계정 혹은 그가 속한 서버는 중재자에 의해 제한되었기 때문에 이 계정의 알림은 걸러졌습니다.",
"notification_requests.maximize": "최대화",
@ -624,8 +636,8 @@
"poll.closed": "마감",
"poll.refresh": "새로고침",
"poll.reveal": "결과 보기",
"poll.total_people": "{count}명",
"poll.total_votes": "{count} 표",
"poll.total_people": "{count, plural, other {#}} 명",
"poll.total_votes": "{count, plural, other {#}} 표",
"poll.vote": "투표",
"poll.voted": "이 답변에 투표함",
"poll.votes": "{votes} 표",
@ -658,7 +670,7 @@
"relative_time.minutes": "{number}분 전",
"relative_time.seconds": "{number}초 전",
"relative_time.today": "오늘",
"reply_indicator.attachments": "{count, plural, one {#} other {#}}개의 첨부파일",
"reply_indicator.attachments": "{count, plural, other {#}} 개의 첨부파일",
"reply_indicator.cancel": "취소",
"reply_indicator.poll": "투표",
"report.block": "차단",
@ -758,7 +770,7 @@
"status.direct_indicator": "개인적인 멘션",
"status.edit": "수정",
"status.edited": "{date}에 마지막으로 편집됨",
"status.edited_x_times": "{count}번 수정됨",
"status.edited_x_times": "{count, plural, other {{count}}} 번 수정됨",
"status.embed": "임베드",
"status.favourite": "좋아요",
"status.favourites": "{count, plural, other {좋아요}}",

View File

@ -516,7 +516,17 @@
"notification.status": "{name} ką tik paskelbė",
"notification.update": "{name} redagavo įrašą",
"notification_requests.accept": "Priimti",
"notification_requests.accept_multiple": "{count, plural, one {Priimti # prašymą…} few {Priimti # prašymus…} many {Priimti # prašymo…} other {Priimti # prašymų…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Priimti prašymą} few {Priimti prašymus} many {Priimti prašymo} other {Priimti prašymų}}",
"notification_requests.confirm_accept_multiple.message": "Ketini priimti {count, plural, one {# pranešimo prašymą} few {# pranešimų prašymus} many {# pranešimo prašymo} other {# pranešimų prašymų}}. Ar tikrai nori tęsti?",
"notification_requests.confirm_accept_multiple.title": "Priimti pranešimų prašymus?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Atmesti prašymą} few {Atmesti prašymus} many {Atmesti prašymo} other {Atmesti prašymų}}",
"notification_requests.confirm_dismiss_multiple.message": "Ketini atmesti {count, plural, one {# pranešimo prašymą} few {# pranešimų prašymus} many {# pranešimo prašymo} other {# pranešimų prašymų}}. Daugiau negalėsi lengvai pasiekti {count, plural, one {jo} few {jų} many {juos} other {jų}}. Ar tikrai nori tęsti?",
"notification_requests.confirm_dismiss_multiple.title": "Atmesti pranešimų prašymus?",
"notification_requests.dismiss": "Atmesti",
"notification_requests.dismiss_multiple": "{count, plural, one {Atmesti prašymą…} few {Atmesti prašymus…} many {Atmesti prašymo…} other {Atmesti prašymų…}}",
"notification_requests.edit_selection": "Redaguoti",
"notification_requests.exit_selection": "Atlikta",
"notification_requests.explainer_for_limited_account": "Pranešimai iš šios paskyros buvo filtruojami, nes prižiūrėtojas (-a) apribojo paskyrą.",
"notification_requests.explainer_for_limited_remote_account": "Pranešimai iš šios paskyros buvo filtruojami, nes prižiūrėtojas (-a) apribojo paskyrą arba serverį.",
"notification_requests.maximize": "Padidinti",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} heeft zojuist een bericht geplaatst",
"notification.update": "{name} heeft een bericht bewerkt",
"notification_requests.accept": "Accepteren",
"notification_requests.accept_multiple": "{count, plural, one {# verzoek accepteren…} other {# verzoeken accepteren…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Verzoek accepteren} other {Verzoeken accepteren}}",
"notification_requests.confirm_accept_multiple.message": "Je staat op het punt om {count, plural, one {een meldingsverzoek} other {# meldingsverzoeken}} te accepteren. Weet je zeker dat je door wilt gaan?",
"notification_requests.confirm_accept_multiple.title": "Meldingsverzoeken accepteren?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Verzoek afwijzen} other {Verzoeken afwijzen}}",
"notification_requests.confirm_dismiss_multiple.message": "Je staat op het punt om {count, plural, one {een meldingsverzoek} other {# meldingsverzoeken}} af te wijzen. Je zult niet in staat zijn om {count, plural, one {hier} other {hier}} weer gemakkelijk toegang toe te krijgen. Wil je doorgaan?",
"notification_requests.confirm_dismiss_multiple.title": "Meldingsverzoeken afwijzen?",
"notification_requests.dismiss": "Afwijzen",
"notification_requests.dismiss_multiple": "{count, plural, one {# verzoek afwijzen…} other {# verzoeken afwijzen…}}",
"notification_requests.edit_selection": "Bewerken",
"notification_requests.exit_selection": "Klaar",
"notification_requests.explainer_for_limited_account": "Meldingen van dit account zijn gefilterd omdat dit account door een moderator is beperkt.",
"notification_requests.explainer_for_limited_remote_account": "Meldingen van dit account zijn gefilterd omdat dit account of diens server door een moderator is beperkt.",
"notification_requests.maximize": "Maximaliseren",

View File

@ -189,6 +189,7 @@
"confirmations.reply.title": "Перепишем пост?",
"confirmations.unfollow.confirm": "Отписаться",
"confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
"confirmations.unfollow.title": "Отписаться?",
"conversation.delete": "Удалить беседу",
"conversation.mark_as_read": "Отметить как прочитанное",
"conversation.open": "Просмотр беседы",
@ -351,6 +352,7 @@
"home.pending_critical_update.link": "Посмотреть обновления",
"home.pending_critical_update.title": "Доступно критическое обновление безопасности!",
"home.show_announcements": "Показать объявления",
"ignore_notifications_modal.filter_to_act_users": "Вы и далее сможете принять, отвергнуть и жаловаться на пользователей",
"interaction_modal.description.favourite": "С учётной записью Mastodon, вы можете добавить этот пост в избранное, чтобы сохранить его на будущее и дать автору знать, что пост вам понравился.",
"interaction_modal.description.follow": "С учётной записью Mastodon вы можете подписаться на {name}, чтобы получать их посты в своей домашней ленте.",
"interaction_modal.description.reblog": "С учётной записью Mastodon, вы можете продвинуть этот пост, чтобы поделиться им со своими подписчиками.",

View File

@ -487,6 +487,7 @@
"notification.label.private_mention": "Zasebna omemba",
"notification.label.private_reply": "Zasebni odgovor",
"notification.label.reply": "Odgovori",
"notification.mention": "Omemba",
"notification.moderation-warning.learn_more": "Več o tem",
"notification.moderation_warning": "Prejeli ste opozorilo moderatorjev",
"notification.moderation_warning.action_delete_statuses": "Nekatere vaše objave so odstranjene.",
@ -508,6 +509,8 @@
"notification.update": "{name} je uredil(a) objavo",
"notification_requests.accept": "Sprejmi",
"notification_requests.dismiss": "Zavrni",
"notification_requests.edit_selection": "Uredi",
"notification_requests.exit_selection": "Opravljeno",
"notification_requests.maximize": "Maksimiraj",
"notification_requests.notifications_from": "Obvestila od {name}",
"notification_requests.title": "Filtrirana obvestila",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} sapo postoi",
"notification.update": "{name} përpunoi një postim",
"notification_requests.accept": "Pranoje",
"notification_requests.accept_multiple": "{count, plural, one {Pranoni # kërkesë…} other {Pranoni # kërkesa…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Pranojeni kërkesën} other {Pranoje kërkesën}}",
"notification_requests.confirm_accept_multiple.message": "Ju ndan një hap nga pranimi i {count, plural, one {një kërkese njoftimi} other {# kërkesash njoftimi}}. Jeni i sigurt se doni të vazhdohet?",
"notification_requests.confirm_accept_multiple.title": "Të pranohen kërkesa njoftimesh?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Hidheni tej kërkesën} other {Hidhini tej kërkesat}}",
"notification_requests.confirm_dismiss_multiple.message": "Ju ndan një hap nga hedhja tej e {count, plural, one {një kërkese njoftimesh} other {# kërkesash njoftimesh}}. Sdo të jeni në gjendje të shihni sërish {count, plural, one {atë} other {ato}}. Jeni i sigurt se doni të bëhet kjo?",
"notification_requests.confirm_dismiss_multiple.title": "Të hidhen tej kërkesa njoftimesh?",
"notification_requests.dismiss": "Hidhe tej",
"notification_requests.dismiss_multiple": "{count, plural, one {Hidhni tej # kërkesë…} other {Hidhni tej # kërkesa…}}",
"notification_requests.edit_selection": "Përpunoni",
"notification_requests.exit_selection": "U bë",
"notification_requests.explainer_for_limited_account": "Njoftimet prej kësaj llogarie janë filtruar, ngaqë llogaria është kufizuar nga një moderator.",
"notification_requests.explainer_for_limited_remote_account": "Njoftimet prej kësaj llogarie janë filtruar, ngaqë llogaria, ose shërbyesi është kufizuar nga një moderator.",
"notification_requests.maximize": "Maksimizoje",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} az önce gönderdi",
"notification.update": "{name} bir gönderiyi düzenledi",
"notification_requests.accept": "Onayla",
"notification_requests.accept_multiple": "{count, plural, one {# isteği kabul et…} other {# isteği kabul et…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {İsteği kabul et} other {İstekleri kabul et}}",
"notification_requests.confirm_accept_multiple.message": "{count, plural, one {Bir bildirim isteğini} other {# bildirim isteğini}} kabul etmek üzeresiniz. Devam etmek istediğinizden emin misiniz?",
"notification_requests.confirm_accept_multiple.title": "Bildirim isteklerini kabul et?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {İsteği reddet} other {İstekleri reddet}}",
"notification_requests.confirm_dismiss_multiple.message": "{count, plural, one {Bir bildirim isteğini} other {# bildirim isteğini}} reddetmek üzeresiniz. {count, plural, one {Ona} other {Onlara}} tekrar kolayca ulaşamayacaksınz. Devam etmek istediğinizden emin misiniz?",
"notification_requests.confirm_dismiss_multiple.title": "Bildirim isteklerini reddet?",
"notification_requests.dismiss": "Yoksay",
"notification_requests.dismiss_multiple": "{count, plural, one {# isteği reddet…} other {# isteği reddet…}}",
"notification_requests.edit_selection": "Düzenle",
"notification_requests.exit_selection": "Tamamlandı",
"notification_requests.explainer_for_limited_account": "Hesap bir moderatör tarafından sınırlandığı için, bu hesaptan gönderilen bildirimler filtrelendi.",
"notification_requests.explainer_for_limited_remote_account": "Hesap veya sunucusu bir moderatör tarafından sınırlandığı için, bu hesaptan gönderilen bildirimler filtrelendi.",
"notification_requests.maximize": "Büyüt",

View File

@ -518,7 +518,17 @@
"notification.status": "{name} щойно дописує",
"notification.update": "{name} змінює допис",
"notification_requests.accept": "Прийняти",
"notification_requests.accept_multiple": "{count, plural, one {Прийняти # запит…} few {Прийняти # запити…} many {Прийняти # запитів…} other {Прийняти # запит…}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Прийняти запит} other {Прийняти запити}}",
"notification_requests.confirm_accept_multiple.message": "Ви збираєтеся прийняти {count, plural, one {запит на сповіщення} few {# запити на сповіщення} many {# запитів на сповіщення} other {# запит на сповіщення}}. Ви впевнені, що хочете продовжити?",
"notification_requests.confirm_accept_multiple.title": "Прийняти запит на сповіщення?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Відхилити запит} other {Відхилити запити}}",
"notification_requests.confirm_dismiss_multiple.message": "Ви збираєтеся відхилити {count, plural, one {один запит на сповіщення} few {# запити на сповіщення} many {# запитів на сповіщення} other {# запит на сповіщення}}. Ви не зможете легко отримати доступ до {count, plural, one {нього} other {них}} пізніше. Ви впевнені, що хочете продовжити?",
"notification_requests.confirm_dismiss_multiple.title": "Відхилити запити на сповіщення?",
"notification_requests.dismiss": "Відхилити",
"notification_requests.dismiss_multiple": "{count, plural, one {Відхилити # запит…} few {Відхилити # запити…} many {Відхилити # запитів…} other {Відхилити # запит…}}",
"notification_requests.edit_selection": "Змінити",
"notification_requests.exit_selection": "Готово",
"notification_requests.explainer_for_limited_account": "Сповіщення від цього облікового запису фільтровані, оскільки обліковий запис обмежений модератором.",
"notification_requests.explainer_for_limited_remote_account": "Сповіщення від цього облікового запису фільтровані, оскільки обліковий запис або його сервер обмежений модератором.",
"notification_requests.maximize": "Розгорнути",

View File

@ -20,12 +20,16 @@ import {
mountNotifications,
unmountNotifications,
refreshStaleNotificationGroups,
pollRecentNotifications,
} from 'mastodon/actions/notification_groups';
import {
disconnectTimeline,
timelineDelete,
} from 'mastodon/actions/timelines_typed';
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
import type {
ApiNotificationJSON,
ApiNotificationGroupJSON,
} from 'mastodon/api_types/notifications';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems } from 'mastodon/initial_state';
import {
@ -296,6 +300,106 @@ function commitLastReadId(state: NotificationGroupsState) {
}
}
function fillNotificationsGap(
groups: NotificationGroupsState['groups'],
gap: NotificationGap,
notifications: ApiNotificationGroupJSON[],
): NotificationGroupsState['groups'] {
// find the gap in the existing notifications
const gapIndex = groups.findIndex(
(groupOrGap) =>
groupOrGap.type === 'gap' &&
groupOrGap.sinceId === gap.sinceId &&
groupOrGap.maxId === gap.maxId,
);
if (gapIndex < 0)
// We do not know where to insert, let's return
return groups;
// Filling a disconnection gap means we're getting historical data
// about groups we may know or may not know about.
// The notifications timeline is split in two by the gap, with
// group information newer than the gap, and group information older
// than the gap.
// Filling a gap should not touch anything before the gap, so any
// information on groups already appearing before the gap should be
// discarded, while any information on groups appearing after the gap
// can be updated and re-ordered.
const oldestPageNotification = notifications.at(-1)?.page_min_id;
// replace the gap with the notifications + a new gap
const newerGroupKeys = groups
.slice(0, gapIndex)
.filter(isNotificationGroup)
.map((group) => group.group_key);
const toInsert: NotificationGroupsState['groups'] = notifications
.map((json) => createNotificationGroupFromJSON(json))
.filter((notification) => !newerGroupKeys.includes(notification.group_key));
const apiGroupKeys = (toInsert as NotificationGroup[]).map(
(group) => group.group_key,
);
const sinceId = gap.sinceId;
if (
notifications.length > 0 &&
!(
oldestPageNotification &&
sinceId &&
compareId(oldestPageNotification, sinceId) <= 0
)
) {
// If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap
// Similarly, if we've fetched more than the gap's, this means we have completely filled it
toInsert.push({
type: 'gap',
maxId: notifications.at(-1)?.page_max_id,
sinceId,
} as NotificationGap);
}
// Remove older groups covered by the API
groups = groups.filter(
(groupOrGap) =>
groupOrGap.type !== 'gap' && !apiGroupKeys.includes(groupOrGap.group_key),
);
// Replace the gap with API results (+ the new gap if needed)
groups.splice(gapIndex, 1, ...toInsert);
// Finally, merge any adjacent gaps that could have been created by filtering
// groups earlier
mergeGaps(groups);
return groups;
}
// Ensure the groups list starts with a gap, mutating it to prepend one if needed
function ensureLeadingGap(
groups: NotificationGroupsState['groups'],
): NotificationGap {
if (groups[0]?.type === 'gap') {
// We're expecting new notifications, so discard the maxId if there is one
groups[0].maxId = undefined;
return groups[0];
} else {
const gap: NotificationGap = {
type: 'gap',
sinceId: groups[0]?.page_min_id,
};
groups.unshift(gap);
return gap;
}
}
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
initialState,
(builder) => {
@ -309,86 +413,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
updateLastReadId(state);
})
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
const { notifications } = action.payload;
// find the gap in the existing notifications
const gapIndex = state.groups.findIndex(
(groupOrGap) =>
groupOrGap.type === 'gap' &&
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
groupOrGap.maxId === action.meta.arg.gap.maxId,
state.groups = fillNotificationsGap(
state.groups,
action.meta.arg.gap,
action.payload.notifications,
);
state.isLoading = false;
if (gapIndex < 0)
// We do not know where to insert, let's return
return;
// Filling a disconnection gap means we're getting historical data
// about groups we may know or may not know about.
// The notifications timeline is split in two by the gap, with
// group information newer than the gap, and group information older
// than the gap.
// Filling a gap should not touch anything before the gap, so any
// information on groups already appearing before the gap should be
// discarded, while any information on groups appearing after the gap
// can be updated and re-ordered.
const oldestPageNotification = notifications.at(-1)?.page_min_id;
// replace the gap with the notifications + a new gap
const newerGroupKeys = state.groups
.slice(0, gapIndex)
.filter(isNotificationGroup)
.map((group) => group.group_key);
const toInsert: NotificationGroupsState['groups'] = notifications
.map((json) => createNotificationGroupFromJSON(json))
.filter(
(notification) => !newerGroupKeys.includes(notification.group_key),
updateLastReadId(state);
})
.addCase(pollRecentNotifications.fulfilled, (state, action) => {
if (usePendingItems) {
const gap = ensureLeadingGap(state.pendingGroups);
state.pendingGroups = fillNotificationsGap(
state.pendingGroups,
gap,
action.payload.notifications,
);
} else {
const gap = ensureLeadingGap(state.groups);
state.groups = fillNotificationsGap(
state.groups,
gap,
action.payload.notifications,
);
const apiGroupKeys = (toInsert as NotificationGroup[]).map(
(group) => group.group_key,
);
const sinceId = action.meta.arg.gap.sinceId;
if (
notifications.length > 0 &&
!(
oldestPageNotification &&
sinceId &&
compareId(oldestPageNotification, sinceId) <= 0
)
) {
// If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap
// Similarly, if we've fetched more than the gap's, this means we have completely filled it
toInsert.push({
type: 'gap',
maxId: notifications.at(-1)?.page_max_id,
sinceId,
} as NotificationGap);
}
// Remove older groups covered by the API
state.groups = state.groups.filter(
(groupOrGap) =>
groupOrGap.type !== 'gap' &&
!apiGroupKeys.includes(groupOrGap.group_key),
);
// Replace the gap with API results (+ the new gap if needed)
state.groups.splice(gapIndex, 1, ...toInsert);
// Finally, merge any adjacent gaps that could have been created by filtering
// groups earlier
mergeGaps(state.groups);
state.isLoading = false;
updateLastReadId(state);
trimNotifications(state);
})
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload;
@ -403,10 +457,11 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
})
.addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') {
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
state.groups.unshift({
const groups = usePendingItems ? state.pendingGroups : state.groups;
if (groups.length > 0 && groups[0]?.type !== 'gap') {
groups.unshift({
type: 'gap',
sinceId: state.groups[0]?.page_min_id,
sinceId: groups[0]?.page_min_id,
});
}
}
@ -453,12 +508,13 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}
}
}
trimNotifications(state);
});
// Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = [];
mergeGaps(state.groups);
trimNotifications(state);
})
.addCase(updateScrollPosition.fulfilled, (state, action) => {
state.scrolledToTop = action.payload.top;
@ -518,13 +574,21 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
},
)
.addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
isAnyOf(
fetchNotifications.pending,
fetchNotificationsGap.pending,
pollRecentNotifications.pending,
),
(state) => {
state.isLoading = true;
},
)
.addMatcher(
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected),
isAnyOf(
fetchNotifications.rejected,
fetchNotificationsGap.rejected,
pollRecentNotifications.rejected,
),
(state) => {
state.isLoading = false;
},

View File

@ -130,21 +130,11 @@
.older {
float: left;
padding-inline-start: 0;
.fa {
display: inline-block;
margin-inline-end: 5px;
}
}
.newer {
float: right;
padding-inline-end: 0;
.fa {
display: inline-block;
margin-inline-start: 5px;
}
}
.disabled {

View File

@ -122,10 +122,6 @@ $content-width: 840px;
overflow: hidden;
text-overflow: ellipsis;
i.fa {
margin-inline-end: 5px;
}
&:hover {
color: $primary-text-color;
transition: all 100ms linear;
@ -306,10 +302,6 @@ $content-width: 840px;
box-shadow: none;
}
.directory__tag .table-action-link .fa {
color: inherit;
}
.directory__tag h4 {
font-size: 18px;
font-weight: 700;

View File

@ -2891,10 +2891,6 @@ $ui-header-logo-wordmark-width: 99px;
padding-inline-end: 30px;
}
.search__icon .fa {
top: 15px;
}
.scrollable {
overflow: visible;
@ -4217,11 +4213,12 @@ a.status-card {
.timeline-hint {
text-align: center;
color: $darker-text-color;
padding: 15px;
color: $dark-text-color;
padding: 16px;
box-sizing: border-box;
width: 100%;
cursor: default;
font-size: 14px;
line-height: 21px;
strong {
font-weight: 500;
@ -4238,10 +4235,10 @@ a.status-card {
color: lighten($highlight-text-color, 4%);
}
}
}
.timeline-hint--with-descendants {
border-top: 1px solid var(--background-border-color);
&--with-descendants {
border-top: 1px solid var(--background-border-color);
}
}
.regeneration-indicator {
@ -6246,7 +6243,7 @@ a.status-card {
}
.boost-modal__container {
overflow-x: scroll;
overflow-y: auto;
padding: 10px;
.status {

View File

@ -113,10 +113,6 @@
flex: 1 1 auto;
}
.fa {
flex: 0 0 auto;
}
strong {
font-weight: 700;
}

View File

@ -930,10 +930,6 @@ code {
font-weight: 700;
}
}
.fa {
font-weight: 400;
}
}
}
}

View File

@ -41,14 +41,6 @@ body.rtl {
no-repeat left 8px center / auto 16px;
}
.fa-chevron-left::before {
content: '\F054';
}
.fa-chevron-right::before {
content: '\F053';
}
.dismissable-banner,
.warning-banner {
&__action {

View File

@ -142,11 +142,6 @@ a.table-action-link {
color: $highlight-text-color;
}
i.fa {
font-weight: 400;
margin-inline-end: 5px;
}
&:first-child {
padding-inline-start: 0;
}

View File

@ -169,11 +169,6 @@
&__message {
margin-bottom: 15px;
.fa {
margin-inline-end: 5px;
color: $darker-text-color;
}
}
&__card {
@ -366,9 +361,7 @@
padding-inline-end: 16px;
}
.fa {
font-size: 16px;
.icon {
&.active {
color: $highlight-text-color;
}

View File

@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration,
:registrations
:registrations, :api_versions
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer
@ -95,6 +95,12 @@ class REST::InstanceSerializer < ActiveModel::Serializer
}
end
def api_versions
{
mastodon: 1,
}
end
private
def registrations_enabled?

View File

@ -1,11 +1,10 @@
:ruby
show_results = (user_signed_in? && poll.voted?(current_account)) || poll.expired?
own_votes = user_signed_in? ? poll.own_votes(current_account) : []
total_votes_count = poll.voters_count || poll.votes_count
.poll
%ul
- poll.loaded_options.each_with_index do |option, index|
- poll.loaded_options.each do |option|
%li
- if show_results
- percent = total_votes_count.positive? ? 100 * option.votes_count / total_votes_count : 0
@ -14,9 +13,6 @@
#{percent.round}%
%span.poll__option__text
= prerender_custom_emojis(h(option.title), status.emojis)
- if own_votes.include?(index)
%span.poll__voted
%i.poll__voted__mark.fa.fa-check
%progress{ max: 100, value: [percent, 1].max, 'aria-hidden': 'true' }
%span.poll__chart

View File

@ -117,4 +117,4 @@ ko:
not_found: 찾을 수 없습니다
not_locked: 잠기지 않았습니다
not_saved:
other: "%{count}개의 에러로 인해 %{resource}가 저장 될 수 없습니다:"
other: "%{count} 개의 에러로 인해 %{resource}가 저장 될 수 없습니다:"

View File

@ -493,7 +493,7 @@ gl:
content_policies:
comment: Nota interna
description_html: Podes definir políticas acerca do contido que serán aplicadas a tódalas contas deste dominio e tódolos seus subdominios.
limited_federation_mode_description_html: Podes elexir se permites ou non a federación con este dominio.
limited_federation_mode_description_html: Podes escoller se permites ou non a federación con este dominio.
policies:
reject_media: Rexeitar multimedia
reject_reports: Rexeitar denuncias

View File

@ -257,8 +257,10 @@ ko:
destroy_user_role_html: "%{name} 님이 %{target} 역할을 삭제했습니다"
disable_2fa_user_html: "%{name} 님이 사용자 %{target} 님의 2단계 인증을 비활성화 했습니다"
disable_custom_emoji_html: "%{name} 님이 에모지 %{target}를 비활성화했습니다"
disable_sign_in_token_auth_user_html: "%{name} 님이 %{target} 님의 이메일 토큰 인증을 비활성화했습니다"
disable_user_html: "%{name} 님이 사용자 %{target}의 로그인을 비활성화했습니다"
enable_custom_emoji_html: "%{name} 님이 에모지 %{target}를 활성화했습니다"
enable_sign_in_token_auth_user_html: "%{name} 님이 %{target} 님의 이메일 토큰 인증을 활성화했습니다"
enable_user_html: "%{name} 님이 사용자 %{target}의 로그인을 활성화했습니다"
memorialize_account_html: "%{name} 님이 %{target}의 계정을 고인의 계정 페이지로 전환했습니다"
promote_user_html: "%{name} 님이 사용자 %{target}를 승급시켰습니다"
@ -266,6 +268,7 @@ ko:
reject_user_html: "%{name} 님이 %{target} 님의 가입을 거부했습니다"
remove_avatar_user_html: "%{name} 님이 %{target} 님의 아바타를 지웠습니다"
reopen_report_html: "%{name} 님이 신고 %{target}을 다시 열었습니다"
resend_user_html: "%{name} 님이 %{target} 님에 대한 확인 메일을 다시 보냈습니다"
reset_password_user_html: "%{name} 님이 사용자 %{target}의 암호를 초기화했습니다"
resolve_report_html: "%{name} 님이 %{target}번 신고를 해결로 변경하였습니다"
sensitive_account_html: "%{name} 님이 %{target}의 미디어를 민감함으로 표시했습니다"
@ -423,6 +426,7 @@ ko:
allow_registrations_with_approval: 승인을 통한 가입 허용
attempts_over_week:
other: 지난 주 동안 %{count}건의 가입 시도가 있었습니다
created_msg: 이메일 도메인 차단 규칙을 생성했습니다
delete: 삭제하기
dns:
types:
@ -432,7 +436,9 @@ ko:
create: 도메인 추가하기
resolve: 도메인 검사
title: 새 이메일 도메인 차단
no_email_domain_block_selected: 아무 것도 선택 되지 않아 어떤 이메일 도메인 차단도 변경되지 않았습니다
not_permitted: 허용하지 않음
resolved_dns_records_hint_html: 도메인 네임은 다음의 MX 도메인으로 연결되어 있으며, 이메일을 받는데 필수적입니다. MX 도메인을 차단하면 같은 MX 도메인을 사용하는 어떤 이메일이라도 가입할 수 없게 되며, 보여지는 도메인이 다르더라도 적용됩니다. <strong>주요 이메일 제공자를 차단하지 않도록 조심하세요.</strong>
resolved_through_html: "%{domain}을 통해 리졸빙됨"
title: 차단된 이메일 도메인
export_domain_allows:
@ -584,6 +590,7 @@ ko:
resolve_description_html: 신고된 계정에 대해 아무런 동작도 취하지 않으며, 처벌기록이 남지 않으며, 신고는 처리됨으로 변경됩니다.
silence_description_html: 이 계정을 이미 팔로우 하고 있는 사람이나 수동으로 찾아보는 사람에게만 프로필이 보여지고, 도달 범위를 엄격하게 제한합니다. 언제든지 되돌릴 수 있습니다. 이 계정에 대한 모든 신고를 닫습니다.
suspend_description_html: 이 계정과 이 계정의 콘텐츠들은 접근 불가능해지고 삭제될 것이며, 상호작용은 불가능해집니다. 30일 이내에 되돌릴 수 있습니다. 이 계정에 대한 모든 신고를 닫습니다.
actions_description_html: 이 신고를 해결하기 위해 취해야 할 조치를 지정해주세요. 신고된 계정에 대해 처벌 조치를 취하면, <strong>스팸</strong> 카테고리가 선택된 경우를 제외하고 해당 계정으로 이메일 알림이 전송됩니다.
actions_description_remote_html: 이 신고를 해결하기 위해 실행할 행동을 결정하세요. 이 결정은 이 원격 계정과 그 콘텐츠를 다루는 방식에 대해 <strong>이 서버</strong>에서만 영향을 끼칩니다
add_to_report: 신고에 더 추가하기
already_suspended_badges:
@ -648,6 +655,7 @@ ko:
delete_data_html: "<strong>@%{acct}</strong>의 프로필과 콘텐츠를 30일의 유예기간 이후에 삭제합니다"
preview_preamble_html: "<strong>@%{acct}</strong>는 다음 내용의 경고를 받게 됩니다:"
record_strike_html: 향후 규칙위반에 대한 참고사항이 될 수 있도록 <strong>@%{acct}</strong>에 대한 처벌기록을 남깁니다
send_email_html: "<strong>@%{acct}</strong>에게 경고 메일을 보냅니다"
warning_placeholder: 중재 결정에 대한 추가적인 이유.
target_origin: 신고된 계정의 소속
title: 신고
@ -685,6 +693,7 @@ ko:
manage_appeals: 이의제기 관리
manage_appeals_description: 사용자가 중재에 대한 이의제기를 리뷰할 수 있도록 허용
manage_blocks: 차단 관리
manage_blocks_description: 사용자가 이메일 제공자와 IP 주소를 차단할 수 있도록 허용
manage_custom_emojis: 커스텀 에모지 관리
manage_custom_emojis_description: 사용자가 서버의 커스텀 에모지를 관리할 수 있도록 허용
manage_federation: 연합 관리
@ -702,6 +711,7 @@ ko:
manage_taxonomies: 분류 관리
manage_taxonomies_description: 사용자가 트렌드를 리뷰하고 해시태그 설정을 수정할 수 있도록 허용합니다
manage_user_access: 사용자 접근 관리
manage_user_access_description: 사용자가 다른 사용자의 2차 인증을 비활성화 하거나, 이메일 주소를 바꾸거나, 암호를 초기화 할 수 있도록 허용
manage_users: 사용자 관리
manage_users_description: 사용자가 다른 사용자의 상세정보를 보고 해당 사용자에 대한 중재활동을 할 수 있도록 허용
manage_webhooks: 웹훅 관리
@ -776,6 +786,7 @@ ko:
destroyed_msg: 사이트 업로드를 성공적으로 삭제했습니다!
software_updates:
critical_update: 긴급 — 빠른 업데이트 요망
description: 최신 수정 사항과 기능을 활용하기 위해 Mastodon 설치를 최신 상태로 유지하기를 권장합니다. 더욱이, 때로는 보안 문제를 피하기 위해 Mastodon을 적절한 시점에 긴급 업데이트해야 하는 경우도 있습니다. 따라서 Mastodon은 30분마다 업데이트를 확인하며, 이메일 알림 환경설정에 따라 사용자에게 알려드립니다.
documentation_link: 더 알아보기
release_notes: 릴리스 노트
title: 사용 가능한 업데이트
@ -884,12 +895,18 @@ ko:
trends:
allow: 허용
approved: 승인됨
disallow: 거부
confirm_allow: 정말로 선택된 태그들을 허용하시겠습니까?
confirm_disallow: 정말로 선택된 태그들을 금지하시겠습니까?
disallow: 금지
links:
allow: 링크 허용
allow_provider: 발행처 허용
confirm_allow: 정말로 선택된 링크들을 허용하시겠습니까?
confirm_allow_provider: 정말로 선택된 제공자들을 허용하시겠습니까?
confirm_disallow: 정말로 선택된 링크들을 금지하시겠습니까?
confirm_disallow_provider: 정말로 선택된 제공자들을 금지하시겠습니까?
description_html: 현재 서버에서 게시물을 볼 수 있는 계정에서 많이 공유되고 있는 링크들입니다. 사용자가 세상 돌아가는 상황을 파악하는 데 도움이 됩니다. 출처를 승인할 때까지 링크는 공개적으로 게시되지 않습니다. 각각의 링크를 개별적으로 허용하거나 거부할 수도 있습니다.
disallow: 링크 거부
disallow: 링크 금지
disallow_provider: 발행처 비허용
no_link_selected: 아무 것도 선택 되지 않아 어떤 링크도 바뀌지 않았습니다
publishers:
@ -910,9 +927,13 @@ ko:
statuses:
allow: 게시물 허용
allow_account: 작성자 허용
confirm_allow: 정말로 선택된 게시물들을 허용하시겠습니까?
confirm_allow_account: 정말로 선택된 계정들을 허용하시겠습니까?
confirm_disallow: 정말로 선택된 게시물들을 금지하시겠습니까?
confirm_disallow_account: 정말로 선택된 계정들을 금지하시겠습니까?
description_html: 여러분의 서버가 알기로 현재 많은 수의 공유와 좋아요가 되고 있는 게시물들입니다. 새로운 사용자나 돌아오는 사용자들이 팔로우 할 사람들을 찾는 데 도움이 될 수 있습니다. 작성자를 승인하고, 작성자가 그들의 계정이 다른 계정에게 탐색되도록 설정하지 않는 한 게시물들은 공개적으로 표시되지 않습니다. 또한 각각의 게시물을 별개로 거절할 수도 있습니다.
disallow: 게시물 비허용
disallow_account: 작성자 비허용
disallow_account: 작성자 금지
no_status_selected: 아무 것도 선택 되지 않아 어떤 유행중인 게시물도 바뀌지 않았습니다
not_discoverable: 작성자가 발견되기를 원치 않습니다
shared_by:
@ -940,6 +961,7 @@ ko:
usage_comparison: 오늘은 %{today}회 쓰였고, 어제는 %{yesterday}회 쓰임
used_by_over_week:
other: 지난 주 동안 %{count} 명의 사람들이 사용했습니다
title: 추천과 유행
trending: 유행 중
warning_presets:
add_new: 새로 추가
@ -1053,6 +1075,7 @@ ko:
redirect_to_app_html: 곧 <strong>%{app_name}</strong>으로 리디렉션 됩니다. 안 된다면, %{clicking_this_link}하거나 직접 앱으로 돌아가세요.
registration_complete: 지금 막 %{domain} 가입이 완료되었습니다!
welcome_title: "%{name} 님 반갑습니다!"
wrong_email_hint: 만약 이메일 주소가 올바르지 않다면, 계정 설정에서 변경할 수 있습니다.
delete_account: 계정 삭제
delete_account_html: 계정을 삭제하고 싶은 경우, <a href="%{path}">여기서</a> 삭제할 수 있습니다. 삭제 전 확인 화면이 표시됩니다.
description:
@ -1098,6 +1121,7 @@ ko:
email_below_hint_html: 스팸 폴더를 체크해보거나, 새로 요청할 수 있습니다. 이메일을 잘못 입력한 경우 수정할 수 있습니다.
email_settings_hint_html: "%{email}을 인증하기 위해 우리가 보낸 링크를 누르세요. 여기서 기다리겠습니다."
link_not_received: 링크를 못 받으셨나요?
new_confirmation_instructions_sent: 확인 링크가 담긴 이메일이 몇 분 안에 도착할것입니다!
title: 수신함 확인하기
sign_in:
preamble_html: "<strong>%{domain}</strong>의 계정 정보를 이용해 로그인 하세요. 만약 내 계정이 다른 서버에 존재한다면, 여기서는 로그인 할 수 없습니다."
@ -1108,7 +1132,9 @@ ko:
title: "%{domain}에 가입하기 위한 정보들을 입력하세요."
status:
account_status: 계정 상태
confirming: 이메일 확인 과정이 완료되기를 기다리는 중.
functional: 계정이 완벽히 작동합니다.
pending: 당신의 가입 신청은 스태프의 검사를 위해 대기 중입니다. 시간이 조금 걸릴 수 있습니다. 가입 신청이 승인되면 이메일을 받게 됩니다.
redirecting_to: 계정이 %{acct}로 리다이렉트 중이기 때문에 비활성 상태입니다.
self_destruct: "%{domain} 도메인 폐쇄가 진행중이기 때문에, 계정에는 제한된 접근만 할 수 있습니다."
view_strikes: 내 계정에 대한 과거 중재 기록 보기
@ -1151,6 +1177,9 @@ ko:
before: '진행하기 전, 주의사항을 꼼꼼히 읽어보세요:'
caches: 다른 서버에 캐싱된 정보들은 남아있을 수 있습니다
data_removal: 당신의 게시물과 다른 정보들은 영구적으로 삭제 됩니다
email_change_html: 계정을 지우지 않고도 <a href="%{path}">이메일 주소를 변경할 수 있습니다</a>
email_contact_html: 아직 도착하지 않았다면, <a href="mailto:%{email}">%{email}</a>에 메일을 보내 도움을 요청할 수 있습니다
email_reconfirmation_html: 아직 확인 메일이 도착하지 않은 경우, <a href="%{path}">다시 요청할 수 있습니다</a>
irreversible: 계정을 복구하거나 다시 사용할 수 없게 됩니다
more_details_html: 더 자세한 정보는, <a href="%{terms_path}">개인정보처리방침</a>을 참고하세요.
username_available: 당신의 계정명은 다시 사용할 수 있게 됩니다
@ -1386,6 +1415,16 @@ ko:
unsubscribe:
action: 네, 구독 취소합니다
complete: 구독 취소됨
confirmation_html: 정말로 %{domain}에서 %{email}로 보내는 마스토돈의 %{type}에 대한 구독을 취소하시겠습니까? 언제든지 <a href="%{settings_path}">이메일 알림 설정</a>에서 다시 구독할 수 있습니다.
emails:
notification_emails:
favourite: 좋아요 알림 이메일
follow: 팔로우 알림 이메일
follow_request: 팔로우 요청 이메일
mention: 멘션 알림 이메일
reblog: 부스트 알림 이메일
resubscribe_html: 만약 실수로 구독 취소를 했다면 <a href="%{settings_path}">이메일 알림 설정</a>에서 다시 구독할 수 있습니다.
success_html: 이제 더이상 %{domain}의 마스토돈에서 %{email}로 %{type} 알림을 보내지 않습니다.
title: 구독 취소
media_attachments:
validations:
@ -1466,6 +1505,8 @@ ko:
update:
subject: "%{name} 님이 게시물을 수정했습니다"
notifications:
administration_emails: 관리자 이메일 알림
email_events: 이메일 알림에 대한 이벤트
email_events_hint: '알림 받을 이벤트를 선택해주세요:'
number:
human:
@ -1624,6 +1665,7 @@ ko:
import: 데이터 가져오기
import_and_export: 가져오기 & 내보내기
migrate: 계정 이동
notifications: 이메일 알림
preferences: 환경설정
profile: 공개 프로필
relationships: 팔로잉과 팔로워
@ -1646,12 +1688,12 @@ ko:
statuses:
attached:
audio:
other: "%{count}개의 오디오"
other: "%{count} 개의 오디오"
description: '첨부: %{attached}'
image:
other: "%{count}장의 이미지"
video:
other: "%{count}개의 영상"
other: "%{count} 개의 영상"
boosted_from_html: "%{acct_link}의 글을 부스트"
content_warning: '열람 주의: %{warning}'
default_language: 화면 표시 언어와 동일하게
@ -1671,7 +1713,7 @@ ko:
total_people:
other: "%{count}명"
total_votes:
other: "%{count}명 투표함"
other: "%{count} 명 투표함"
vote: 투표
show_more: 더 보기
show_thread: 글타래 보기
@ -1863,6 +1905,7 @@ ko:
invalid_otp_token: 2단계 인증 코드가 올바르지 않습니다
otp_lost_help_html: 만약 양쪽 모두를 잃어버렸다면 %{email}을 통해 복구할 수 있습니다
rate_limited: 너무 많은 인증 시도가 있었습니다, 잠시 후에 시도하세요.
seamless_external_login: 외부 서비스를 이용해 로그인했으므로 이메일과 암호는 설정할 수 없습니다.
signed_in_as: '다음과 같이 로그인 중:'
verification:
extra_instructions_html: <strong>팁:</strong> 웹사이트에 안 보이는 링크로 삽입할 수 있습니다. 중요한 것은 나를 도용하는 것을 방지하는 <code>rel="me"</code> 부분입니다. 심지어 <code>a</code> 대신 <code>link</code>태그를 페이지 헤더에 넣는 것으로 대체할 수도 있습니다. 하지만 HTML 코드는 자바스크립트 실행 없이 접근이 가능해야 합니다.

View File

@ -35,6 +35,7 @@ ru:
created_msg: Заметка модератора успешно создана!
destroyed_msg: Заметка модератора успешно удалена!
accounts:
add_email_domain_block: Забанить email домен
approve: Подтвердить
approved_msg: Успешно одобрена заявка на регистрацию %{username}
are_you_sure: Вы уверены?
@ -428,6 +429,7 @@ ru:
many: "%{count} попыток за последнюю неделю"
one: "%{count} попытка за последнюю неделю"
other: "%{count} попыток регистрации за последнюю неделю"
created_msg: Домен email забанен, ура
delete: Удалить
dns:
types:
@ -599,6 +601,9 @@ ru:
suspend_description_html: Аккаунт и все его содержимое будут недоступны и в конечном итоге удалены, и взаимодействие с ним будет невозможно. Это действие можно отменить в течение 30 дней. Отменяет все жалобы против этого аккаунта.
actions_description_remote_html: Решите вопрос о том, какие меры необходимо принять для урегулирования этой жалобы. Это повлияет только на то, как <strong>ваш</strong> сервер взаимодействует с этим удаленным аккаунтом и обрабатывает его содержимое.
add_to_report: Прикрепить ещё
already_suspended_badges:
local: На этом сервере уже забанен
remote: На этом сервере уже забанен
are_you_sure: Вы уверены?
assign_to_self: Назначить себе
assigned: Назначенный модератор
@ -752,6 +757,7 @@ ru:
desc_html: Это зависит от внешних скриптов из hCaptcha, которые могут представлять интерес для безопасности и конфиденциальности. Кроме того, <strong>это может сделать процесс регистрации значительно менее доступным для некоторых (особенно отключенных) людей</strong>. По этим причинам просьба рассмотреть альтернативные меры, такие, как регистрация, основанная на официальном утверждении или на приглашении.
title: Запрашивать новых пользователей для решения CAPTCHA для подтверждения учетной записи
content_retention:
danger_zone: Осторожно!
preamble: Управление сохранением пользовательского контента в Mastodon.
title: Хранение контента
default_noindex:

View File

@ -138,7 +138,7 @@ gl:
permissions_as_keys: As usuarias con este rol terán acceso a...
position: O rol superior decide nos conflitos en certas situacións. Algunhas accións só poden aplicarse sobre roles cunha prioridade menor
webhook:
events: Elixir eventos a enviar
events: Escoller eventos a enviar
template: Crea o teu propio JSON interpolando variables. Déixao en branco para usar o JSON predeterminado.
url: A onde se enviarán os eventos
labels:

View File

@ -238,6 +238,7 @@ ru:
warn: Скрыть с предупреждением
form_admin_settings:
activity_api_enabled: Публикация агрегированной статистики активности пользователей в API
app_icon: Иконка приложения
backups_retention_period: Период хранения архива пользователя
bootstrap_timeline_accounts: Всегда рекомендовать эти учетные записи новым пользователям
closed_registrations_message: Сообщение, когда регистрация недоступна
@ -307,6 +308,7 @@ ru:
listable: Разрешить показ хэштега в поиске или в каталоге профилей
name: Хэштег
trendable: Разрешить показ хэштега в трендах
usable: Позволить этот хэштег в локальных сообщениях
user:
role: Роль
time_zone: Часовой пояс

View File

@ -18,6 +18,7 @@ describe 'Instances' do
expect(body_as_json)
.to be_present
.and include(title: 'Mastodon Glitch Edition')
.and include_api_versions
.and include_configuration_limits
end
end
@ -32,6 +33,7 @@ describe 'Instances' do
expect(body_as_json)
.to be_present
.and include(title: 'Mastodon Glitch Edition')
.and include_api_versions
.and include_configuration_limits
end
end
@ -53,5 +55,13 @@ describe 'Instances' do
)
)
end
def include_api_versions
include(
api_versions: include(
mastodon: anything
)
)
end
end
end