Merge commit '82344342c1c5adb3f6a4b376559db737a9e982b7' into glitch-soc/merge-upstream

pull/2782/head
Claire 2024-07-18 17:56:25 +02:00
commit c75fe09e2b
85 changed files with 5461 additions and 337 deletions

View File

@ -50,6 +50,11 @@ You can contribute in the following ways:
If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
Please review the org-level [contribution guidelines] for high-level acceptance
criteria guidance.
[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md
## API Changes and Additions ## API Changes and Additions
Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation).

View File

@ -766,8 +766,9 @@ GEM
ruby-saml (1.16.0) ruby-saml (1.16.0)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.1) ruby-vips (2.2.2)
ffi (~> 1.12) ffi (~> 1.12)
logger
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
rufus-scheduler (3.9.1) rufus-scheduler (3.9.1)

View File

@ -12,10 +12,27 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
with_read_replica do with_read_replica do
@notifications = load_notifications @notifications = load_notifications
@group_metadata = load_group_metadata @group_metadata = load_group_metadata
@grouped_notifications = load_grouped_notifications
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
@sample_accounts = @grouped_notifications.flat_map(&:sample_accounts)
# Preload associations to avoid N+1s
ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
end end
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }
span.add_attributes(
'app.notification_grouping.count' => @grouped_notifications.size,
'app.notification_grouping.sample_account.count' => @sample_accounts.size,
'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size,
'app.notification_grouping.status.count' => statuses.size,
'app.notification_grouping.status.unique_count' => statuses.uniq.size
)
render json: @grouped_notifications, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
end
end end
def show def show
@ -36,25 +53,35 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
private private
def load_notifications def load_notifications
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id( MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
limit_param(DEFAULT_NOTIFICATIONS_LIMIT), notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
params_slice(:max_id, :since_id, :min_id) limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
) params_slice(:max_id, :since_id, :min_id)
)
Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
preload_collection(target_statuses, Status) preload_collection(target_statuses, Status)
end
end end
end end
def load_group_metadata def load_group_metadata
return {} if @notifications.empty? return {} if @notifications.empty?
browserable_account_notifications MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
.where(group_key: @notifications.filter_map(&:group_key)) browserable_account_notifications
.where(id: (@notifications.last.id)..(@notifications.first.id)) .where(group_key: @notifications.filter_map(&:group_key))
.group(:group_key) .where(id: (@notifications.last.id)..(@notifications.first.id))
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at') .group(:group_key)
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] } .pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
end
end
def load_grouped_notifications
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
end
end end
def browserable_account_notifications def browserable_account_notifications

View File

@ -75,9 +75,17 @@ interface MarkerParam {
} }
function getLastNotificationId(state: RootState): string | undefined { function getLastNotificationId(state: RootState): string | undefined {
// @ts-expect-error state.notifications is not yet typed // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call const enableBeta = state.settings.getIn(
return state.getIn(['notifications', 'lastReadId']); ['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta
? state.notificationGroups.lastReadId
: // @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
state.getIn(['notifications', 'lastReadId']);
} }
const buildPostMarkersParams = (state: RootState) => { const buildPostMarkersParams = (state: RootState) => {

View File

@ -0,0 +1,144 @@
import { createAction } from '@reduxjs/toolkit';
import {
apiClearNotifications,
apiFetchNotifications,
} from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type {
ApiNotificationGroupJSON,
ApiNotificationJSON,
} from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
} from 'mastodon/selectors/settings';
import type { AppDispatch } from 'mastodon/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from 'mastodon/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';
function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
}
function dispatchAssociatedRecords(
dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
) {
const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = [];
notifications.forEach((notification) => {
if ('sample_accounts' in notification) {
fetchedAccounts.push(...notification.sample_accounts);
}
if (notification.type === 'admin.report') {
fetchedAccounts.push(notification.report.target_account);
}
if (notification.type === 'moderation_warning') {
fetchedAccounts.push(notification.moderation_warning.target_account);
}
if ('status' in notification) {
fetchedStatuses.push(notification.status);
}
});
if (fetchedAccounts.length > 0)
dispatch(importFetchedAccounts(fetchedAccounts));
if (fetchedStatuses.length > 0)
dispatch(importFetchedStatuses(fetchedStatuses));
}
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) => {
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
notifications;
// TODO: might be worth not using gaps for that…
// if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
if (notifications.length > 1)
payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });
return payload;
// dispatch(submitMarkers());
},
);
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) =>
apiFetchNotifications({ max_id: params.gap.maxId }),
({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);
return { notifications };
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch }) => {
dispatchAssociatedRecords(dispatch, [notification]);
return notification;
},
);
export const loadPending = createAction('notificationGroups/loadPending');
export const updateScrollPosition = createAction<{ top: boolean }>(
'notificationGroups/updateScrollPosition',
);
export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => {
dispatch({
type: NOTIFICATIONS_FILTER_SET,
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications());
dispatch(saveSettings());
},
);
export const clearNotifications = createDataLoadingThunk(
'notifications/clear',
() => apiClearNotifications(),
);
export const markNotificationsAsRead = createAction(
'notificationGroups/markAsRead',
);
export const mountNotifications = createAction('notificationGroups/mount');
export const unmountNotifications = createAction('notificationGroups/unmount');

View File

@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
@ -174,7 +173,7 @@ const noOp = () => {};
let expandNotificationsController = new AbortController(); let expandNotificationsController = new AbortController();
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) { export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
return (dispatch, getState) => { return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications'); const notifications = getState().get('notifications');
@ -257,16 +256,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
}; };
} }
export function clearNotifications() {
return (dispatch) => {
dispatch({
type: NOTIFICATIONS_CLEAR,
});
api().post('/api/v1/notifications/clear');
};
}
export function scrollTopNotifications(top) { export function scrollTopNotifications(top) {
return { return {
type: NOTIFICATIONS_SCROLL_TOP, type: NOTIFICATIONS_SCROLL_TOP,

View File

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'mastodon/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = getState().settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
if (enableBeta) void dispatch(fetchNotifications());
else dispatch(expandNotifications());
},
);

View File

@ -1,11 +1,6 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from '../api_types/accounts'; import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
// To be replaced once ApiNotificationJSON type exists
interface FakeApiNotificationJSON {
type: string;
account: ApiAccountJSON;
}
export const notificationsUpdate = createAction( export const notificationsUpdate = createAction(
'notifications/update', 'notifications/update',
@ -13,7 +8,7 @@ export const notificationsUpdate = createAction(
playSound, playSound,
...args ...args
}: { }: {
notification: FakeApiNotificationJSON; notification: ApiNotificationJSON;
usePendingItems: boolean; usePendingItems: boolean;
playSound: boolean; playSound: boolean;
}) => ({ }) => ({

View File

@ -10,6 +10,7 @@ import {
deleteAnnouncement, deleteAnnouncement,
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { processNewNotificationForGroups } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'delete': case 'delete':
dispatch(deleteFromTimelines(data.payload)); dispatch(deleteFromTimelines(data.payload));
break; break;
case 'notification': case 'notification': {
// @ts-expect-error // @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one
if(getState().notificationGroups.groups.length > 0) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
break; break;
}
case 'conversation': case 'conversation':
// @ts-expect-error // @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload))); dispatch(updateConversations(JSON.parse(data.payload)));

View File

@ -0,0 +1,18 @@
import api, { apiRequest, getLinks } from 'mastodon/api';
import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';
export const apiFetchNotifications = async (params?: {
exclude_types?: string[];
max_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupJSON[]>({
method: 'GET',
url: '/api/v2_alpha/notifications',
params,
});
return { notifications: response.data, links: getLinks(response) };
};
export const apiClearNotifications = () =>
apiRequest<undefined>('POST', 'v1/notifications/clear');

View File

@ -0,0 +1,145 @@
// See app/serializers/rest/notification_group_serializer.rb
import type { AccountWarningAction } from 'mastodon/models/notification_group';
import type { ApiAccountJSON } from './accounts';
import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb
export const allNotificationTypes = [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'status',
'update',
'admin.sign_up',
'admin.report',
'moderation_warning',
'severed_relationships',
];
export type NotificationWithStatusType =
| 'favourite'
| 'reblog'
| 'status'
| 'mention'
| 'poll'
| 'update';
export type NotificationType =
| NotificationWithStatusType
| 'follow'
| 'follow_request'
| 'moderation_warning'
| 'severed_relationships'
| 'admin.sign_up'
| 'admin.report';
export interface BaseNotificationJSON {
id: string;
type: NotificationType;
created_at: string;
group_key: string;
account: ApiAccountJSON;
}
export interface BaseNotificationGroupJSON {
group_key: string;
notifications_count: number;
type: NotificationType;
sample_accounts: ApiAccountJSON[];
latest_page_notification_at: string; // FIXME: This will only be present if the notification group is returned in a paginated list, not requested directly
most_recent_notification_id: string;
page_min_id?: string;
page_max_id?: string;
}
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
type: NotificationWithStatusType;
status: ApiStatusJSON;
}
interface NotificationWithStatusJSON extends BaseNotificationJSON {
type: NotificationWithStatusType;
status: ApiStatusJSON;
}
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'admin.report';
report: ApiReportJSON;
}
interface ReportNotificationJSON extends BaseNotificationJSON {
type: 'admin.report';
report: ApiReportJSON;
}
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
type: SimpleNotificationTypes;
}
interface SimpleNotificationJSON extends BaseNotificationJSON {
type: SimpleNotificationTypes;
}
export interface ApiAccountWarningJSON {
id: string;
action: AccountWarningAction;
text: string;
status_ids: string[];
created_at: string;
target_account: ApiAccountJSON;
appeal: unknown;
}
interface ModerationWarningNotificationGroupJSON
extends BaseNotificationGroupJSON {
type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON;
}
interface ModerationWarningNotificationJSON extends BaseNotificationJSON {
type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON;
}
export interface ApiAccountRelationshipSeveranceEventJSON {
id: string;
type: 'account_suspension' | 'domain_block' | 'user_domain_block';
purged: boolean;
target_name: string;
followers_count: number;
following_count: number;
created_at: string;
}
interface AccountRelationshipSeveranceNotificationGroupJSON
extends BaseNotificationGroupJSON {
type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON;
}
interface AccountRelationshipSeveranceNotificationJSON
extends BaseNotificationJSON {
type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON;
}
export type ApiNotificationJSON =
| SimpleNotificationJSON
| ReportNotificationJSON
| AccountRelationshipSeveranceNotificationJSON
| NotificationWithStatusJSON
| ModerationWarningNotificationJSON;
export type ApiNotificationGroupJSON =
| SimpleNotificationGroupJSON
| ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON
| ModerationWarningNotificationGroupJSON;

View File

@ -0,0 +1,16 @@
import type { ApiAccountJSON } from './accounts';
export type ReportCategory = 'other' | 'spam' | 'legal' | 'violation';
export interface ApiReportJSON {
id: string;
action_taken: unknown;
action_taken_at: unknown;
category: ReportCategory;
comment: string;
forwarded: boolean;
created_at: string;
status_ids: string[];
rule_ids: string[];
target_account: ApiAccountJSON;
}

View File

@ -9,18 +9,18 @@ const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
}); });
interface Props { interface Props<T> {
disabled: boolean; disabled: boolean;
maxId: string; param: T;
onClick: (maxId: string) => void; onClick: (params: T) => void;
} }
export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => { export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
const intl = useIntl(); const intl = useIntl();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(maxId); onClick(param);
}, [maxId, onClick]); }, [param, onClick]);
return ( return (
<button <button

View File

@ -116,6 +116,8 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
skipPrepend: PropTypes.bool,
avatarSize: PropTypes.number,
deployPictureInPicture: PropTypes.func, deployPictureInPicture: PropTypes.func,
pictureInPicture: ImmutablePropTypes.contains({ pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool, inUse: PropTypes.bool,
@ -353,7 +355,7 @@ class Status extends ImmutablePureComponent {
}; };
render () { render () {
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
let { status, account, ...other } = this.props; let { status, account, ...other } = this.props;
@ -539,7 +541,7 @@ class Status extends ImmutablePureComponent {
} }
if (account === undefined || account === null) { if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />; statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
} else { } else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
} }
@ -550,7 +552,7 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend} {!skipPrepend && prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />} {(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}

View File

@ -107,7 +107,7 @@ export default class StatusList extends ImmutablePureComponent {
<LoadGap <LoadGap
key={'gap:' + statusIds.get(index + 1)} key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading} disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null} param={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore} onClick={onLoadMore}
/> />
); );

View File

@ -13,6 +13,7 @@ import { cancelReplyCompose } from 'mastodon/actions/compose';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
const messages = defineMessages({ const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
@ -33,8 +34,6 @@ export const EditIndicator = () => {
return null; return null;
} }
const content = { __html: status.get('contentHtml') };
return ( return (
<div className='edit-indicator'> <div className='edit-indicator'>
<div className='edit-indicator__header'> <div className='edit-indicator__header'>
@ -49,7 +48,12 @@ export const EditIndicator = () => {
</div> </div>
</div> </div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} /> <EmbeddedStatusContent
className='edit-indicator__content translate'
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'> <div className='edit-indicator__attachments'>

View File

@ -9,6 +9,7 @@ import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content';
export const ReplyIndicator = () => { export const ReplyIndicator = () => {
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to'])); const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
@ -19,8 +20,6 @@ export const ReplyIndicator = () => {
return null; return null;
} }
const content = { __html: status.get('contentHtml') };
return ( return (
<div className='reply-indicator'> <div className='reply-indicator'>
<div className='reply-indicator__line' /> <div className='reply-indicator__line' />
@ -34,7 +33,12 @@ export const ReplyIndicator = () => {
<DisplayName account={account} /> <DisplayName account={account} />
</Link> </Link>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> <EmbeddedStatusContent
className='reply-indicator__content translate'
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='reply-indicator__attachments'> <div className='reply-indicator__attachments'>

View File

@ -53,6 +53,7 @@ class ColumnSettings extends PureComponent {
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />; const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
const groupingShowStr = <FormattedMessage id='notifications.column_settings.beta.grouping' defaultMessage='Group notifications' />;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
@ -104,6 +105,16 @@ class ColumnSettings extends PureComponent {
</div> </div>
</section> </section>
<section role='group' aria-labelledby='notifications-beta'>
<h3 id='notifications-beta'>
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
</h3>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-unread-markers'> <section role='group' aria-labelledby='notifications-unread-markers'>
<h3 id='notifications-unread-markers'> <h3 id='notifications-unread-markers'>
<FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' /> <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />

View File

@ -35,7 +35,9 @@ export const FilteredNotificationsBanner: React.FC = () => {
className='filtered-notifications-banner' className='filtered-notifications-banner'
to='/notifications/requests' to='/notifications/requests'
> >
<Icon icon={InventoryIcon} id='filtered-notifications' /> <div className='notification-group__icon'>
<Icon icon={InventoryIcon} id='filtered-notifications' />
</div>
<div className='filtered-notifications-banner__text'> <div className='filtered-notifications-banner__text'>
<strong> <strong>

View File

@ -1,7 +1,10 @@
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import GavelIcon from '@/material-icons/400-24px/gavel.svg?react'; import GavelIcon from '@/material-icons/400-24px/gavel.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import type { AccountWarningAction } from 'mastodon/models/notification_group';
// This needs to be kept in sync with app/models/account_warning.rb // This needs to be kept in sync with app/models/account_warning.rb
const messages = defineMessages({ const messages = defineMessages({
@ -36,19 +39,18 @@ const messages = defineMessages({
}); });
interface Props { interface Props {
action: action: AccountWarningAction;
| 'none'
| 'disable'
| 'mark_statuses_as_sensitive'
| 'delete_statuses'
| 'sensitive'
| 'silence'
| 'suspend';
id: string; id: string;
hidden: boolean; hidden?: boolean;
unread?: boolean;
} }
export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => { export const ModerationWarning: React.FC<Props> = ({
action,
id,
hidden,
unread,
}) => {
const intl = useIntl(); const intl = useIntl();
if (hidden) { if (hidden) {
@ -56,23 +58,32 @@ export const ModerationWarning: React.FC<Props> = ({ action, id, hidden }) => {
} }
return ( return (
<a <div
href={`/disputes/strikes/${id}`} role='button'
target='_blank' className={classNames(
rel='noopener noreferrer' 'notification-group notification-group--link notification-group--moderation-warning focusable',
className='notification__moderation-warning' { 'notification-group--unread': unread },
)}
tabIndex={0}
> >
<Icon id='warning' icon={GavelIcon} /> <div className='notification-group__icon'>
<Icon id='warning' icon={GavelIcon} />
</div>
<div className='notification__moderation-warning__content'> <div className='notification-group__main'>
<p>{intl.formatMessage(messages[action])}</p> <p>{intl.formatMessage(messages[action])}</p>
<span className='link-button'> <a
href={`/disputes/strikes/${id}`}
target='_blank'
rel='noopener noreferrer'
className='link-button'
>
<FormattedMessage <FormattedMessage
id='notification.moderation-warning.learn_more' id='notification.moderation-warning.learn_more'
defaultMessage='Learn more' defaultMessage='Learn more'
/> />
</span> </a>
</div> </div>
</a> </div>
); );
}; };

View File

@ -34,7 +34,7 @@ const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' }, favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' }, follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' }, status: { id: 'notification.status', defaultMessage: '{name} just posted' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
@ -340,7 +340,7 @@ class Notification extends ImmutablePureComponent {
{ownPoll ? ( {ownPoll ? (
<FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' /> <FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
) : ( ) : (
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' /> <FormattedMessage id='notification.poll' defaultMessage='A poll you voted in has ended' />
)} )}
</span> </span>
</div> </div>

View File

@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react'; import HeartBrokenIcon from '@/material-icons/400-24px/heart_broken-fill.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { domain } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';
@ -13,7 +15,7 @@ const messages = defineMessages({
user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' }, user_domain_block: { id: 'notification.relationships_severance_event.user_domain_block', defaultMessage: 'You have blocked {target}, removing {followersCount} of your followers and {followingCount, plural, one {# account} other {# accounts}} you follow.' },
}); });
export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden }) => { export const RelationshipsSeveranceEvent = ({ type, target, followingCount, followersCount, hidden, unread }) => {
const intl = useIntl(); const intl = useIntl();
if (hidden) { if (hidden) {
@ -21,14 +23,14 @@ export const RelationshipsSeveranceEvent = ({ type, target, followingCount, foll
} }
return ( return (
<a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='notification__relationships-severance-event'> <div role='button' className={classNames('notification-group notification-group--link notification-group--relationships-severance-event focusable', { 'notification-group--unread': unread })} tabIndex='0'>
<Icon id='heart_broken' icon={HeartBrokenIcon} /> <div className='notification-group__icon'><Icon id='heart_broken' icon={HeartBrokenIcon} /></div>
<div className='notification__relationships-severance-event__content'> <div className='notification-group__main'>
<p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p> <p>{intl.formatMessage(messages[type], { from: <strong>{domain}</strong>, target: <strong>{target}</strong>, followingCount, followersCount })}</p>
<span className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></span> <a href='/severed_relationships' target='_blank' rel='noopener noreferrer' className='link-button'><FormattedMessage id='notification.relationships_severance_event.learn_more' defaultMessage='Learn more' /></a>
</div> </div>
</a> </div>
); );
}; };
@ -42,4 +44,5 @@ RelationshipsSeveranceEvent.propTypes = {
followersCount: PropTypes.number.isRequired, followersCount: PropTypes.number.isRequired,
followingCount: PropTypes.number.isRequired, followingCount: PropTypes.number.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool,
}; };

View File

@ -2,10 +2,13 @@ import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { initializeNotifications } from 'mastodon/actions/notifications_migration';
import { showAlert } from '../../../actions/alerts'; import { showAlert } from '../../../actions/alerts';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { clearNotifications } from '../../../actions/notification_groups';
import { updateNotificationsPolicy } from '../../../actions/notification_policies'; import { updateNotificationsPolicy } from '../../../actions/notification_policies';
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { changeSetting } from '../../../actions/settings'; import { changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
@ -58,6 +61,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
} else if(path[0] === 'groupingBeta') {
dispatch(changeSetting(['notifications', ...path], checked));
dispatch(initializeNotifications());
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }

View File

@ -202,7 +202,7 @@ class Notifications extends PureComponent {
<LoadGap <LoadGap
key={'gap:' + notifications.getIn([index + 1, 'id'])} key={'gap:' + notifications.getIn([index + 1, 'id'])}
disabled={isLoading} disabled={isLoading}
maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
onClick={this.handleLoadGap} onClick={this.handleLoadGap}
/> />
) : ( ) : (

View File

@ -0,0 +1,31 @@
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) return null;
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<Avatar account={account} size={28} />
</Link>
);
};
export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
accountIds,
}) => (
<div className='notification-group__avatar-group'>
{accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
<AvatarWrapper key={accountId} accountId={accountId} />
))}
</div>
);

View File

@ -0,0 +1,93 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import type { Status } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store';
import { EmbeddedStatusContent } from './embedded_status_content';
export type Mention = RecordOf<{ url: string; acct: string }>;
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
statusId,
}) => {
const history = useHistory();
const status = useAppSelector(
(state) => state.statuses.get(statusId) as Status | undefined,
);
const account = useAppSelector((state) =>
state.accounts.get(status?.get('account') as string),
);
const handleClick = useCallback(() => {
if (!account) return;
history.push(`/@${account.acct}/${statusId}`);
}, [statusId, account, history]);
if (!status) {
return null;
}
// Assign status attributes to variables with a forced type, as status is not yet properly typed
const contentHtml = status.get('contentHtml') as string;
const poll = status.get('poll');
const language = status.get('language') as string;
const mentions = status.get('mentions') as ImmutableList<Mention>;
const mediaAttachmentsSize = (
status.get('media_attachments') as ImmutableList<unknown>
).size;
return (
<div className='notification-group__embedded-status'>
<div className='notification-group__embedded-status__account'>
<Avatar account={account} size={16} />
<DisplayName account={account} />
</div>
<EmbeddedStatusContent
className='notification-group__embedded-status__content reply-indicator__content translate'
content={contentHtml}
language={language}
mentions={mentions}
onClick={handleClick}
/>
{(poll || mediaAttachmentsSize > 0) && (
<div className='notification-group__embedded-status__attachments reply-indicator__attachments'>
{!!poll && (
<>
<Icon icon={BarChart4BarsIcon} id='bar-chart-4-bars' />
<FormattedMessage
id='reply_indicator.poll'
defaultMessage='Poll'
/>
</>
)}
{mediaAttachmentsSize > 0 && (
<>
<Icon icon={PhotoLibraryIcon} id='photo-library' />
<FormattedMessage
id='reply_indicator.attachments'
defaultMessage='{count, plural, one {# attachment} other {# attachments}}'
values={{ count: mediaAttachmentsSize }}
/>
</>
)}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,165 @@
import { useCallback, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import type { List } from 'immutable';
import type { History } from 'history';
import type { Mention } from './embedded_status';
const handleMentionClick = (
history: History,
mention: Mention,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/@${mention.get('acct')}`);
}
};
const handleHashtagClick = (
history: History,
hashtag: string,
e: MouseEvent,
) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/tags/${hashtag.replace(/^#/, '')}`);
}
};
export const EmbeddedStatusContent: React.FC<{
content: string;
mentions: List<Mention>;
language: string;
onClick?: () => void;
className?: string;
}> = ({ content, mentions, language, onClick, className }) => {
const clickCoordinatesRef = useRef<[number, number] | null>();
const history = useHistory();
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY }) => {
clickCoordinatesRef.current = [clientX, clientY];
},
[clickCoordinatesRef],
);
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY, target, button }) => {
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
const [deltaX, deltaY] = [
Math.abs(clientX - startX),
Math.abs(clientY - startY),
];
let element: HTMLDivElement | null = target as HTMLDivElement;
while (element) {
if (
element.localName === 'button' ||
element.localName === 'a' ||
element.localName === 'label'
) {
return;
}
element = element.parentNode as HTMLDivElement | null;
}
if (deltaX + deltaY < 5 && button === 0 && onClick) {
onClick();
}
clickCoordinatesRef.current = null;
},
[clickCoordinatesRef, onClick],
);
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-original');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-static');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
const handleContentRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node) {
return;
}
const links = node.querySelectorAll<HTMLAnchorElement>('a');
for (const link of links) {
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
const mention = mentions.find((item) => link.href === item.get('url'));
if (mention) {
link.addEventListener(
'click',
handleMentionClick.bind(null, history, mention),
false,
);
link.setAttribute('title', `@${mention.get('acct')}`);
link.setAttribute('href', `/@${mention.get('acct')}`);
} else if (
link.textContent?.[0] === '#' ||
link.previousSibling?.textContent?.endsWith('#')
) {
link.addEventListener(
'click',
handleHashtagClick.bind(null, history, link.text),
false,
);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}
}
},
[mentions, history],
);
return (
<div
role='button'
tabIndex={0}
className={className}
ref={handleContentRef}
lang={language}
dangerouslySetInnerHTML={{ __html: content }}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};

View File

@ -0,0 +1,51 @@
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

@ -0,0 +1,132 @@
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import type { NotificationGroupAdminReport } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
// This needs to be kept in sync with app/models/report.rb
const messages = defineMessages({
other: {
id: 'report_notification.categories.other_sentence',
defaultMessage: 'other',
},
spam: {
id: 'report_notification.categories.spam_sentence',
defaultMessage: 'spam',
},
legal: {
id: 'report_notification.categories.legal_sentence',
defaultMessage: 'illegal content',
},
violation: {
id: 'report_notification.categories.violation_sentence',
defaultMessage: 'rule violation',
},
});
export const NotificationAdminReport: React.FC<{
notification: NotificationGroupAdminReport;
unread?: boolean;
}> = ({ notification, notification: { report }, unread }) => {
const intl = useIntl();
const targetAccount = useAppSelector((state) =>
state.accounts.get(report.targetAccountId),
);
const account = useAppSelector((state) =>
state.accounts.get(notification.sampleAccountIds[0] ?? '0'),
);
if (!account || !targetAccount) return null;
const values = {
name: (
<bdi
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
/>
),
target: (
<bdi
dangerouslySetInnerHTML={{
__html: targetAccount.get('display_name_html'),
}}
/>
),
category: intl.formatMessage(messages[report.category]),
count: report.status_ids.length,
};
let message;
if (report.status_ids.length > 0) {
if (report.category === 'other') {
message = (
<FormattedMessage
id='notification.admin.report_account_other'
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target}'
values={values}
/>
);
} else {
message = (
<FormattedMessage
id='notification.admin.report_account'
defaultMessage='{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}'
values={values}
/>
);
}
} else {
if (report.category === 'other') {
message = (
<FormattedMessage
id='notification.admin.report_statuses_other'
defaultMessage='{name} reported {target}'
values={values}
/>
);
} else {
message = (
<FormattedMessage
id='notification.admin.report_statuses'
defaultMessage='{name} reported {target} for {category}'
values={values}
/>
);
}
}
return (
<a
href={`/admin/reports/${report.id}`}
target='_blank'
rel='noopener noreferrer'
className={classNames(
'notification-group notification-group--link notification-group--admin-report focusable',
{ 'notification-group--unread': unread },
)}
>
<div className='notification-group__icon'>
<Icon id='flag' icon={FlagIcon} />
</div>
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__label'>
{message}
<RelativeTimestamp timestamp={report.created_at} />
</div>
</div>
{report.comment.length > 0 && (
<div className='notification-group__embedded-status__content'>
{report.comment}
</div>
)}
</div>
</a>
);
};

View File

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import type { NotificationGroupAdminSignUp } from 'mastodon/models/notification_group';
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}
/>
);
export const NotificationAdminSignUp: React.FC<{
notification: NotificationGroupAdminSignUp;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationGroupWithStatus
type='admin-sign-up'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View File

@ -0,0 +1,45 @@
import { FormattedMessage } from 'react-intl';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import type { NotificationGroupFavourite } from 'mastodon/models/notification_group';
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}
/>
);
export const NotificationFavourite: React.FC<{
notification: NotificationGroupFavourite;
unread: boolean;
}> = ({ notification, unread }) => {
const { statusId } = notification;
const statusAccount = useAppSelector(
(state) =>
state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
?.acct,
);
return (
<NotificationGroupWithStatus
type='favourite'
icon={StarIcon}
iconId='star'
accountIds={notification.sampleAccountIds}
statusId={notification.statusId}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
labelSeeMoreHref={
statusAccount ? `/@${statusAccount}/${statusId}/favourites` : undefined
}
unread={unread}
/>
);
};

View File

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import type { NotificationGroupFollow } from 'mastodon/models/notification_group';
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}
/>
);
export const NotificationFollow: React.FC<{
notification: NotificationGroupFollow;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationGroupWithStatus
type='follow'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View File

@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import {
authorizeFollowRequest,
rejectFollowRequest,
} from 'mastodon/actions/accounts';
import { IconButton } from 'mastodon/components/icon_button';
import type { NotificationGroupFollowRequest } from 'mastodon/models/notification_group';
import { useAppDispatch } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
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}
/>
);
export const NotificationFollowRequest: React.FC<{
notification: NotificationGroupFollowRequest;
unread: boolean;
}> = ({ notification, unread }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onAuthorize = useCallback(() => {
dispatch(authorizeFollowRequest(notification.sampleAccountIds[0]));
}, [dispatch, notification.sampleAccountIds]);
const onReject = useCallback(() => {
dispatch(rejectFollowRequest(notification.sampleAccountIds[0]));
}, [dispatch, notification.sampleAccountIds]);
const actions = (
<div className='notification-group__actions'>
<IconButton
title={intl.formatMessage(messages.reject)}
icon='times'
iconComponent={CloseIcon}
onClick={onReject}
/>
<IconButton
title={intl.formatMessage(messages.authorize)}
icon='check'
iconComponent={CheckIcon}
onClick={onAuthorize}
/>
</div>
);
return (
<NotificationGroupWithStatus
type='follow-request'
icon={PersonAddIcon}
iconId='person-add'
accountIds={notification.sampleAccountIds}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
actions={actions}
unread={unread}
/>
);
};

View File

@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { HotKeys } from 'react-hotkeys';
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up';
import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request';
import { NotificationMention } from './notification_mention';
import { NotificationModerationWarning } from './notification_moderation_warning';
import { NotificationPoll } from './notification_poll';
import { NotificationReblog } from './notification_reblog';
import { NotificationSeveredRelationships } from './notification_severed_relationships';
import { NotificationStatus } from './notification_status';
import { NotificationUpdate } from './notification_update';
export const NotificationGroup: React.FC<{
notificationGroupId: NotificationGroupModel['group_key'];
unread: boolean;
onMoveUp: (groupId: string) => void;
onMoveDown: (groupId: string) => void;
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
const notificationGroup = useAppSelector((state) =>
state.notificationGroups.groups.find(
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
),
);
const handlers = useMemo(
() => ({
moveUp: () => {
onMoveUp(notificationGroupId);
},
moveDown: () => {
onMoveDown(notificationGroupId);
},
}),
[notificationGroupId, onMoveUp, onMoveDown],
);
if (!notificationGroup || notificationGroup.type === 'gap') return null;
let content;
switch (notificationGroup.type) {
case 'reblog':
content = (
<NotificationReblog unread={unread} notification={notificationGroup} />
);
break;
case 'favourite':
content = (
<NotificationFavourite
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'severed_relationships':
content = (
<NotificationSeveredRelationships
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'mention':
content = (
<NotificationMention unread={unread} notification={notificationGroup} />
);
break;
case 'follow':
content = (
<NotificationFollow unread={unread} notification={notificationGroup} />
);
break;
case 'follow_request':
content = (
<NotificationFollowRequest
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'poll':
content = (
<NotificationPoll unread={unread} notification={notificationGroup} />
);
break;
case 'status':
content = (
<NotificationStatus unread={unread} notification={notificationGroup} />
);
break;
case 'update':
content = (
<NotificationUpdate unread={unread} notification={notificationGroup} />
);
break;
case 'admin.sign_up':
content = (
<NotificationAdminSignUp
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'admin.report':
content = (
<NotificationAdminReport
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'moderation_warning':
content = (
<NotificationModerationWarning
unread={unread}
notification={notificationGroup}
/>
);
break;
default:
return null;
}
return <HotKeys handlers={handlers}>{content}</HotKeys>;
};

View File

@ -0,0 +1,91 @@
import { useMemo } from 'react';
import classNames from 'classnames';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { AvatarGroup } from './avatar_group';
import { EmbeddedStatus } from './embedded_status';
import { NamesList } from './names_list';
export type LabelRenderer = (
values: Record<string, React.ReactNode>,
) => JSX.Element;
export const NotificationGroupWithStatus: React.FC<{
icon: IconProp;
iconId: string;
statusId?: string;
actions?: JSX.Element;
count: number;
accountIds: string[];
timestamp: string;
labelRenderer: LabelRenderer;
labelSeeMoreHref?: string;
type: string;
unread: boolean;
}> = ({
icon,
iconId,
timestamp,
accountIds,
actions,
count,
statusId,
labelRenderer,
labelSeeMoreHref,
type,
unread,
}) => {
const label = useMemo(
() =>
labelRenderer({
name: (
<NamesList
accountIds={accountIds}
total={count}
seeMoreHref={labelSeeMoreHref}
/>
),
}),
[labelRenderer, accountIds, count, labelSeeMoreHref],
);
return (
<div
role='button'
className={classNames(
`notification-group focusable notification-group--${type}`,
{ 'notification-group--unread': unread },
)}
tabIndex={0}
>
<div className='notification-group__icon'>
<Icon icon={icon} id={iconId} />
</div>
<div className='notification-group__main'>
<div className='notification-group__main__header'>
<div className='notification-group__main__header__wrapper'>
<AvatarGroup accountIds={accountIds} />
{actions}
</div>
<div className='notification-group__main__header__label'>
{label}
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
</div>
</div>
{statusId && (
<div className='notification-group__main__status'>
<EmbeddedStatus statusId={statusId} />
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import { FormattedMessage } from 'react-intl';
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import type { StatusVisibility } from 'mastodon/api_types/statuses';
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.mention'
defaultMessage='{name} mentioned you'
values={values}
/>
);
const privateMentionLabelRenderer: LabelRenderer = (values) => (
<FormattedMessage
id='notification.private_mention'
defaultMessage='{name} privately mentioned you'
values={values}
/>
);
export const NotificationMention: React.FC<{
notification: NotificationGroupMention;
unread: boolean;
}> = ({ notification, unread }) => {
const statusVisibility = useAppSelector(
(state) =>
state.statuses.getIn([
notification.statusId,
'visibility',
]) as StatusVisibility,
);
return (
<NotificationWithStatus
type='mention'
icon={ReplyIcon}
iconId='reply'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={
statusVisibility === 'direct'
? privateMentionLabelRenderer
: labelRenderer
}
unread={unread}
/>
);
};

View File

@ -0,0 +1,13 @@
import { ModerationWarning } from 'mastodon/features/notifications/components/moderation_warning';
import type { NotificationGroupModerationWarning } from 'mastodon/models/notification_group';
export const NotificationModerationWarning: React.FC<{
notification: NotificationGroupModerationWarning;
unread: boolean;
}> = ({ notification: { moderationWarning }, unread }) => (
<ModerationWarning
action={moderationWarning.action}
id={moderationWarning.id}
unread={unread}
/>
);

View File

@ -0,0 +1,41 @@
import { FormattedMessage } from 'react-intl';
import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react';
import { me } from 'mastodon/initial_state';
import type { NotificationGroupPoll } from 'mastodon/models/notification_group';
import { NotificationWithStatus } from './notification_with_status';
const labelRendererOther = () => (
<FormattedMessage
id='notification.poll'
defaultMessage='A poll you voted in has ended'
/>
);
const labelRendererOwn = () => (
<FormattedMessage
id='notification.own_poll'
defaultMessage='Your poll has ended'
/>
);
export const NotificationPoll: React.FC<{
notification: NotificationGroupPoll;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationWithStatus
type='poll'
icon={BarChart4BarsIcon}
iconId='bar-chart-4-bars'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={
notification.sampleAccountIds[0] === me
? labelRendererOwn
: labelRendererOther
}
unread={unread}
/>
);

View File

@ -0,0 +1,45 @@
import { FormattedMessage } from 'react-intl';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import type { NotificationGroupReblog } from 'mastodon/models/notification_group';
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}
/>
);
export const NotificationReblog: React.FC<{
notification: NotificationGroupReblog;
unread: boolean;
}> = ({ notification, unread }) => {
const { statusId } = notification;
const statusAccount = useAppSelector(
(state) =>
state.accounts.get(state.statuses.getIn([statusId, 'account']) as string)
?.acct,
);
return (
<NotificationGroupWithStatus
type='reblog'
icon={RepeatIcon}
iconId='repeat'
accountIds={notification.sampleAccountIds}
statusId={notification.statusId}
timestamp={notification.latest_page_notification_at}
count={notification.notifications_count}
labelRenderer={labelRenderer}
labelSeeMoreHref={
statusAccount ? `/@${statusAccount}/${statusId}/reblogs` : undefined
}
unread={unread}
/>
);
};

View File

@ -0,0 +1,15 @@
import { RelationshipsSeveranceEvent } from 'mastodon/features/notifications/components/relationships_severance_event';
import type { NotificationGroupSeveredRelationships } from 'mastodon/models/notification_group';
export const NotificationSeveredRelationships: React.FC<{
notification: NotificationGroupSeveredRelationships;
unread: boolean;
}> = ({ notification: { event }, unread }) => (
<RelationshipsSeveranceEvent
type={event.type}
target={event.target_name}
followersCount={event.followers_count}
followingCount={event.following_count}
unread={unread}
/>
);

View File

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
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) => (
<FormattedMessage
id='notification.status'
defaultMessage='{name} just posted'
values={values}
/>
);
export const NotificationStatus: React.FC<{
notification: NotificationGroupStatus;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationWithStatus
type='status'
icon={NotificationsActiveIcon}
iconId='notifications-active'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View File

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
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) => (
<FormattedMessage
id='notification.update'
defaultMessage='{name} edited a post'
values={values}
/>
);
export const NotificationUpdate: React.FC<{
notification: NotificationGroupUpdate;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationWithStatus
type='update'
icon={EditIcon}
iconId='edit'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View File

@ -0,0 +1,73 @@
import { useMemo } from 'react';
import classNames from 'classnames';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import Status from 'mastodon/containers/status_container';
import { useAppSelector } from 'mastodon/store';
import { NamesList } from './names_list';
import type { LabelRenderer } from './notification_group_with_status';
export const NotificationWithStatus: React.FC<{
type: string;
icon: IconProp;
iconId: string;
accountIds: string[];
statusId: string;
count: number;
labelRenderer: LabelRenderer;
unread: boolean;
}> = ({
icon,
iconId,
accountIds,
statusId,
count,
labelRenderer,
type,
unread,
}) => {
const label = useMemo(
() =>
labelRenderer({
name: <NamesList accountIds={accountIds} total={count} />,
}),
[labelRenderer, accountIds, count],
);
const isPrivateMention = useAppSelector(
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
);
return (
<div
role='button'
className={classNames(
`notification-ungrouped focusable notification-ungrouped--${type}`,
{
'notification-ungrouped--unread': unread,
'notification-ungrouped--direct': isPrivateMention,
},
)}
tabIndex={0}
>
<div className='notification-ungrouped__header'>
<div className='notification-ungrouped__header__icon'>
<Icon icon={icon} id={iconId} />
</div>
{label}
</div>
<Status
// @ts-expect-error -- <Status> is not yet typed
id={statusId}
contextType='notifications'
withDismiss
skipPrepend
avatarSize={40}
/>
</div>
);
};

View File

@ -0,0 +1,145 @@
import type { PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import { setNotificationsFilter } from 'mastodon/actions/notification_groups';
import { Icon } from 'mastodon/components/icon';
import {
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterAdvanced,
} from 'mastodon/selectors/settings';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: {
id: 'notifications.filter.favourites',
defaultMessage: 'Favorites',
},
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: {
id: 'notifications.filter.statuses',
defaultMessage: 'Updates from people you follow',
},
});
const BarButton: React.FC<
PropsWithChildren<{
selectedFilter: string;
type: string;
title?: string;
}>
> = ({ selectedFilter, type, title, children }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
void dispatch(setNotificationsFilter({ filterType: type }));
}, [dispatch, type]);
return (
<button
className={selectedFilter === type ? 'active' : ''}
onClick={onClick}
title={title}
>
{children}
</button>
);
};
export const FilterBar: React.FC = () => {
const intl = useIntl();
const selectedFilter = useAppSelector(
selectSettingsNotificationsQuickFilterActive,
);
const advancedMode = useAppSelector(
selectSettingsNotificationsQuickFilterAdvanced,
);
if (advancedMode)
return (
<div className='notification__filter-bar'>
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
<FormattedMessage
id='notifications.filter.all'
defaultMessage='All'
/>
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='mention'
key='mention'
title={intl.formatMessage(tooltips.mentions)}
>
<Icon id='reply-all' icon={ReplyAllIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='favourite'
key='favourite'
title={intl.formatMessage(tooltips.favourites)}
>
<Icon id='star' icon={StarIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='reblog'
key='reblog'
title={intl.formatMessage(tooltips.boosts)}
>
<Icon id='retweet' icon={RepeatIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='poll'
key='poll'
title={intl.formatMessage(tooltips.polls)}
>
<Icon id='tasks' icon={InsertChartIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='status'
key='status'
title={intl.formatMessage(tooltips.statuses)}
>
<Icon id='home' icon={HomeIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='follow'
key='follow'
title={intl.formatMessage(tooltips.follows)}
>
<Icon id='user-plus' icon={PersonAddIcon} />
</BarButton>
</div>
);
else
return (
<div className='notification__filter-bar'>
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
<FormattedMessage
id='notifications.filter.all'
defaultMessage='All'
/>
</BarButton>
<BarButton selectedFilter={selectedFilter} type='mention' key='mention'>
<FormattedMessage
id='notifications.filter.mentions'
defaultMessage='Mentions'
/>
</BarButton>
</div>
);
};

View File

@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import { useDebouncedCallback } from 'use-debounce';
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import {
fetchNotificationsGap,
updateScrollPosition,
loadPending,
markNotificationsAsRead,
mountNotifications,
unmountNotifications,
} from 'mastodon/actions/notification_groups';
import { compareId } from 'mastodon/compare_id';
import { Icon } from 'mastodon/components/icon';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { useIdentity } from 'mastodon/identity_context';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import {
selectUnreadNotificationGroupsCount,
selectPendingNotificationGroupsCount,
} from 'mastodon/selectors/notifications';
import {
selectNeedsNotificationPermission,
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsShowUnread,
} from 'mastodon/selectors/settings';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { RootState } from 'mastodon/store';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { submitMarkers } from '../../actions/markers';
import Column from '../../components/column';
import { ColumnHeader } from '../../components/column_header';
import { LoadGap } from '../../components/load_gap';
import ScrollableList from '../../components/scrollable_list';
import { FilteredNotificationsBanner } from '../notifications/components/filtered_notifications_banner';
import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner';
import ColumnSettingsContainer from '../notifications/containers/column_settings_container';
import { NotificationGroup } from './components/notification_group';
import { FilterBar } from './filter_bar';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
markAsRead: {
id: 'notifications.mark_as_read',
defaultMessage: 'Mark every notification as read',
},
});
const getNotifications = createSelector(
[
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsExcludedTypes,
(state: RootState) => state.notificationGroups.groups,
],
(showFilterBar, allowedType, excludedTypes, notifications) => {
if (!showFilterBar || allowedType === 'all') {
// used if user changed the notification settings after loading the notifications from the server
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filter(
(item) => item.type === 'gap' || !excludedTypes.includes(item.type),
);
}
return notifications.filter(
(item) => item.type === 'gap' || allowedType === item.type,
);
},
);
export const Notifications: React.FC<{
columnId?: string;
multiColumn?: boolean;
}> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const notifications = useAppSelector(getNotifications);
const dispatch = useAppDispatch();
const isLoading = useAppSelector((s) => s.notificationGroups.isLoading);
const hasMore = notifications.at(-1)?.type === 'gap';
const lastReadId = useAppSelector((s) =>
selectSettingsNotificationsShowUnread(s)
? s.notificationGroups.lastReadId
: '0',
);
const numPending = useAppSelector(selectPendingNotificationGroupsCount);
const unreadNotificationsCount = useAppSelector(
selectUnreadNotificationGroupsCount,
);
const isUnread = unreadNotificationsCount > 0;
const canMarkAsRead =
useAppSelector(selectSettingsNotificationsShowUnread) &&
unreadNotificationsCount > 0;
const needsNotificationPermission = useAppSelector(
selectNeedsNotificationPermission,
);
const columnRef = useRef<Column>(null);
const selectChild = useCallback((index: number, alignTop: boolean) => {
const container = columnRef.current?.node as HTMLElement | undefined;
if (!container) return;
const element = container.querySelector<HTMLElement>(
`article:nth-of-type(${index + 1}) .focusable`,
);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (
!alignTop &&
container.scrollTop + container.clientHeight <
element.offsetTop + element.offsetHeight
) {
element.scrollIntoView(false);
}
element.focus();
}
}, []);
// Keep track of mounted components for unread notification handling
useEffect(() => {
dispatch(mountNotifications());
return () => {
dispatch(unmountNotifications());
dispatch(updateScrollPosition({ top: false }));
};
}, [dispatch]);
const handleLoadGap = useCallback(
(gap: NotificationGap) => {
void dispatch(fetchNotificationsGap({ gap }));
},
[dispatch],
);
const handleLoadOlder = useDebouncedCallback(
() => {
const gap = notifications.at(-1);
if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap }));
},
300,
{ leading: true },
);
const handleLoadPending = useCallback(() => {
dispatch(loadPending());
}, [dispatch]);
const handleScrollToTop = useDebouncedCallback(() => {
dispatch(updateScrollPosition({ top: true }));
}, 100);
const handleScroll = useDebouncedCallback(() => {
dispatch(updateScrollPosition({ top: false }));
}, 100);
useEffect(() => {
return () => {
handleLoadOlder.cancel();
handleScrollToTop.cancel();
handleScroll.cancel();
};
}, [handleLoadOlder, handleScrollToTop, handleScroll]);
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('NOTIFICATIONS', {}));
}
}, [columnId, dispatch]);
const handleMove = useCallback(
(dir: unknown) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleMoveUp = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) - 1;
selectChild(elementIndex, true);
},
[notifications, selectChild],
);
const handleMoveDown = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) + 1;
selectChild(elementIndex, false);
},
[notifications, selectChild],
);
const handleMarkAsRead = useCallback(() => {
dispatch(markNotificationsAsRead());
void dispatch(submitMarkers({ immediate: true }));
}, [dispatch]);
const pinned = !!columnId;
const emptyMessage = (
<FormattedMessage
id='empty_column.notifications'
defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here."
/>
);
const { signedIn } = useIdentity();
const filterBar = signedIn ? <FilterBar /> : null;
const scrollableContent = useMemo(() => {
if (notifications.length === 0 && !hasMore) return null;
return notifications.map((item) =>
item.type === 'gap' ? (
<LoadGap
key={`${item.maxId}-${item.sinceId}`}
disabled={isLoading}
param={item}
onClick={handleLoadGap}
/>
) : (
<NotificationGroup
key={item.group_key}
notificationGroupId={item.group_key}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
unread={
lastReadId !== '0' &&
!!item.page_max_id &&
compareId(item.page_max_id, lastReadId) > 0
}
/>
),
);
}, [
notifications,
isLoading,
hasMore,
lastReadId,
handleLoadGap,
handleMoveUp,
handleMoveDown,
]);
const prepend = (
<>
{needsNotificationPermission && <NotificationsPermissionBanner />}
<FilteredNotificationsBanner />
</>
);
const scrollContainer = signedIn ? (
<ScrollableList
scrollKey={`notifications-${columnId}`}
trackScroll={!pinned}
isLoading={isLoading}
showLoading={isLoading && notifications.length === 0}
hasMore={hasMore}
numPending={numPending}
prepend={prepend}
alwaysPrepend
emptyMessage={emptyMessage}
onLoadMore={handleLoadOlder}
onLoadPending={handleLoadPending}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
bindToDocument={!multiColumn}
>
{scrollableContent}
</ScrollableList>
) : (
<NotSignedInIndicator />
);
const extraButton = canMarkAsRead ? (
<button
aria-label={intl.formatMessage(messages.markAsRead)}
title={intl.formatMessage(messages.markAsRead)}
onClick={handleMarkAsRead}
className='column-header__button'
>
<Icon id='done-all' icon={DoneAllIcon} />
</button>
) : null;
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
icon='bell'
iconComponent={NotificationsIcon}
active={isUnread}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
extraButton={extraButton}
>
<ColumnSettingsContainer />
</ColumnHeader>
{filterBar}
{scrollContainer}
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Notifications;

View File

@ -0,0 +1,13 @@
import Notifications from 'mastodon/features/notifications';
import Notifications_v2 from 'mastodon/features/notifications_v2';
import { useAppSelector } from 'mastodon/store';
export const NotificationsWrapper = (props) => {
const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
return (
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
);
};
export default NotificationsWrapper;

View File

@ -10,7 +10,7 @@ import { scrollRight } from '../../../scroll';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import { import {
Compose, Compose,
Notifications, NotificationsWrapper,
HomeTimeline, HomeTimeline,
CommunityTimeline, CommunityTimeline,
PublicTimeline, PublicTimeline,
@ -32,7 +32,7 @@ import NavigationPanel from './navigation_panel';
const componentMap = { const componentMap = {
'COMPOSE': Compose, 'COMPOSE': Compose,
'HOME': HomeTimeline, 'HOME': HomeTimeline,
'NOTIFICATIONS': Notifications, 'NOTIFICATIONS': NotificationsWrapper,
'PUBLIC': PublicTimeline, 'PUBLIC': PublicTimeline,
'REMOTE': PublicTimeline, 'REMOTE': PublicTimeline,
'COMMUNITY': CommunityTimeline, 'COMMUNITY': CommunityTimeline,

View File

@ -34,6 +34,7 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile'; import { transientSingleColumn } from 'mastodon/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
import ColumnLink from './column_link'; import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner'; import DisabledAccountBanner from './disabled_account_banner';
@ -59,15 +60,19 @@ const messages = defineMessages({
}); });
const NotificationsLink = () => { const NotificationsLink = () => {
const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
const count = useSelector(state => state.getIn(['notifications', 'unread'])); const count = useSelector(state => state.getIn(['notifications', 'unread']));
const intl = useIntl(); const intl = useIntl();
const newCount = useSelector(selectUnreadNotificationGroupsCount);
return ( return (
<ColumnLink <ColumnLink
key='notifications'
transparent transparent
to='/notifications' to='/notifications'
icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={count} className='column-link__icon' />} icon={<IconWithBadge id='bell' icon={NotificationsIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={count} className='column-link__icon' />} activeIcon={<IconWithBadge id='bell' icon={NotificationsActiveIcon} count={optedInGroupedNotifications ? newCount : count} className='column-link__icon' />}
text={intl.formatMessage(messages.notifications)} text={intl.formatMessage(messages.notifications)}
/> />
); );

View File

@ -13,6 +13,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { initializeNotifications } from 'mastodon/actions/notifications_migration';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { PictureInPicture } from 'mastodon/features/picture_in_picture';
@ -22,7 +23,6 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { expandNotifications } from '../../actions/notifications';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state';
@ -49,7 +49,7 @@ import {
Favourites, Favourites,
DirectTimeline, DirectTimeline,
HashtagTimeline, HashtagTimeline,
Notifications, NotificationsWrapper,
NotificationRequests, NotificationRequests,
NotificationRequest, NotificationRequest,
FollowRequests, FollowRequests,
@ -71,6 +71,7 @@ import {
} from './util/async-components'; } from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context'; import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
import '../../components/status'; import '../../components/status';
@ -205,7 +206,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} /> <WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} /> <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact /> <WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact /> <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact /> <WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
@ -405,7 +406,7 @@ class UI extends PureComponent {
if (signedIn) { if (signedIn) {
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(initializeNotifications());
this.props.dispatch(fetchServerTranslationLanguages()); this.props.dispatch(fetchServerTranslationLanguages());
setTimeout(() => this.props.dispatch(fetchServer()), 3000); setTimeout(() => this.props.dispatch(fetchServer()), 3000);

View File

@ -7,7 +7,15 @@ export function Compose () {
} }
export function Notifications () { export function Notifications () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications'); return import(/* webpackChunkName: "features/notifications_v1" */'../../notifications');
}
export function Notifications_v2 () {
return import(/* webpackChunkName: "features/notifications_v2" */'../../notifications_v2');
}
export function NotificationsWrapper () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications_wrapper');
} }
export function HomeTimeline () { export function HomeTimeline () {

View File

@ -443,6 +443,8 @@
"mute_modal.title": "Mute user?", "mute_modal.title": "Mute user?",
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.", "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.", "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.about": "About",
"navigation_bar.advanced_interface": "Open in advanced web interface", "navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.blocks": "Blocked users", "navigation_bar.blocks": "Blocked users",
@ -470,6 +472,10 @@
"navigation_bar.security": "Security", "navigation_bar.security": "Security",
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.", "not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
"notification.admin.report": "{name} reported {target}", "notification.admin.report": "{name} reported {target}",
"notification.admin.report_account": "{name} reported {count, plural, one {one post} other {# posts}} from {target} for {category}",
"notification.admin.report_account_other": "{name} reported {count, plural, one {one post} other {# posts}} from {target}",
"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} signed up",
"notification.favourite": "{name} favorited your post", "notification.favourite": "{name} favorited your post",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
@ -485,7 +491,8 @@
"notification.moderation_warning.action_silence": "Your account has been limited.", "notification.moderation_warning.action_silence": "Your account has been limited.",
"notification.moderation_warning.action_suspend": "Your account has been suspended.", "notification.moderation_warning.action_suspend": "Your account has been suspended.",
"notification.own_poll": "Your poll has ended", "notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you voted in has ended",
"notification.private_mention": "{name} privately mentioned you",
"notification.reblog": "{name} boosted your post", "notification.reblog": "{name} boosted your post",
"notification.relationships_severance_event": "Lost connections with {name}", "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.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.",
@ -503,6 +510,8 @@
"notifications.column_settings.admin.report": "New reports:", "notifications.column_settings.admin.report": "New reports:",
"notifications.column_settings.admin.sign_up": "New sign-ups:", "notifications.column_settings.admin.sign_up": "New sign-ups:",
"notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.beta.category": "Experimental features",
"notifications.column_settings.beta.grouping": "Group notifications",
"notifications.column_settings.favourite": "Favorites:", "notifications.column_settings.favourite": "Favorites:",
"notifications.column_settings.filter_bar.advanced": "Display all categories", "notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar", "notifications.column_settings.filter_bar.category": "Quick filter bar",
@ -666,9 +675,13 @@
"report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.", "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
"report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached", "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached",
"report_notification.categories.legal": "Legal", "report_notification.categories.legal": "Legal",
"report_notification.categories.legal_sentence": "illegal content",
"report_notification.categories.other": "Other", "report_notification.categories.other": "Other",
"report_notification.categories.other_sentence": "other",
"report_notification.categories.spam": "Spam", "report_notification.categories.spam": "Spam",
"report_notification.categories.spam_sentence": "spam",
"report_notification.categories.violation": "Rule violation", "report_notification.categories.violation": "Rule violation",
"report_notification.categories.violation_sentence": "rule violation",
"report_notification.open": "Open report", "report_notification.open": "Open report",
"search.no_recent_searches": "No recent searches", "search.no_recent_searches": "No recent searches",
"search.placeholder": "Search", "search.placeholder": "Search",

View File

@ -29,8 +29,8 @@
"account.enable_notifications": "Cuir mé in eol nuair bpostálann @{name}", "account.enable_notifications": "Cuir mé in eol nuair bpostálann @{name}",
"account.endorse": "Cuir ar an phróifíl mar ghné", "account.endorse": "Cuir ar an phróifíl mar ghné",
"account.featured_tags.last_status_at": "Postáil is déanaí ar {date}", "account.featured_tags.last_status_at": "Postáil is déanaí ar {date}",
"account.featured_tags.last_status_never": "Níl postáil ar bith ann", "account.featured_tags.last_status_never": "Gan aon phoist",
"account.featured_tags.title": "Haischlib {name}", "account.featured_tags.title": "Haischlib faoi thrácht {name}",
"account.follow": "Lean", "account.follow": "Lean",
"account.follow_back": "Leanúint ar ais", "account.follow_back": "Leanúint ar ais",
"account.followers": "Leantóirí", "account.followers": "Leantóirí",
@ -38,7 +38,7 @@
"account.followers_counter": "{count, plural, one {{counter} leantóir} other {{counter} leantóirí}}", "account.followers_counter": "{count, plural, one {{counter} leantóir} other {{counter} leantóirí}}",
"account.following": "Ag leanúint", "account.following": "Ag leanúint",
"account.following_counter": "{count, plural, one {{counter} ag leanúint} other {{counter} ag leanúint}}", "account.following_counter": "{count, plural, one {{counter} ag leanúint} other {{counter} ag leanúint}}",
"account.follows.empty": "Ní leanann an t-úsáideoir seo duine ar bith fós.", "account.follows.empty": "Ní leanann an t-úsáideoir seo aon duine go fóill.",
"account.go_to_profile": "Téigh go dtí próifíl", "account.go_to_profile": "Téigh go dtí próifíl",
"account.hide_reblogs": "Folaigh moltaí ó @{name}", "account.hide_reblogs": "Folaigh moltaí ó @{name}",
"account.in_memoriam": "Cuimhneachán.", "account.in_memoriam": "Cuimhneachán.",
@ -46,7 +46,7 @@
"account.languages": "Athraigh teangacha foscríofa", "account.languages": "Athraigh teangacha foscríofa",
"account.link_verified_on": "Seiceáladh úinéireacht an naisc seo ar {date}", "account.link_verified_on": "Seiceáladh úinéireacht an naisc seo ar {date}",
"account.locked_info": "Tá an socrú príobháideachais don cuntas seo curtha go 'faoi ghlas'. Déanann an t-úinéir léirmheas ar cén daoine atá ceadaithe an cuntas leanúint.", "account.locked_info": "Tá an socrú príobháideachais don cuntas seo curtha go 'faoi ghlas'. Déanann an t-úinéir léirmheas ar cén daoine atá ceadaithe an cuntas leanúint.",
"account.media": "Ábhair", "account.media": "Meáin",
"account.mention": "Luaigh @{name}", "account.mention": "Luaigh @{name}",
"account.moved_to": "Tá tugtha le fios ag {name} gurb é an cuntas nua atá acu ná:", "account.moved_to": "Tá tugtha le fios ag {name} gurb é an cuntas nua atá acu ná:",
"account.mute": "Balbhaigh @{name}", "account.mute": "Balbhaigh @{name}",
@ -66,7 +66,7 @@
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} poist}}", "account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} poist}}",
"account.unblock": "Bain bac de @{name}", "account.unblock": "Bain bac de @{name}",
"account.unblock_domain": "Bain bac den ainm fearainn {domain}", "account.unblock_domain": "Bain bac den ainm fearainn {domain}",
"account.unblock_short": "Bain bac de", "account.unblock_short": "Díbhlocáil",
"account.unendorse": "Ná chuir ar an phróifíl mar ghné", "account.unendorse": "Ná chuir ar an phróifíl mar ghné",
"account.unfollow": "Ná lean a thuilleadh", "account.unfollow": "Ná lean a thuilleadh",
"account.unmute": "Díbhalbhaigh @{name}", "account.unmute": "Díbhalbhaigh @{name}",
@ -100,7 +100,7 @@
"boost_modal.combo": "Is féidir leat {combo} a bhrú chun é seo a scipeáil an chéad uair eile", "boost_modal.combo": "Is féidir leat {combo} a bhrú chun é seo a scipeáil an chéad uair eile",
"bundle_column_error.copy_stacktrace": "Cóipeáil tuairisc earráide", "bundle_column_error.copy_stacktrace": "Cóipeáil tuairisc earráide",
"bundle_column_error.error.body": "Ní féidir an leathanach a iarradh a sholáthar. Seans gurb amhlaidh mar gheall ar fhabht sa chód, nó mar gheall ar mhíréireacht leis an mbrabhsálaí.", "bundle_column_error.error.body": "Ní féidir an leathanach a iarradh a sholáthar. Seans gurb amhlaidh mar gheall ar fhabht sa chód, nó mar gheall ar mhíréireacht leis an mbrabhsálaí.",
"bundle_column_error.error.title": "Ná habair!", "bundle_column_error.error.title": "Ó, níl sé sin go maith!",
"bundle_column_error.network.body": "Tharla earráid agus an leathanach á lódáil. Seans gur mar gheall ar fhadhb shealadach le do nasc idirlín nó i ndáil leis an bhfreastalaí seo atá sé.", "bundle_column_error.network.body": "Tharla earráid agus an leathanach á lódáil. Seans gur mar gheall ar fhadhb shealadach le do nasc idirlín nó i ndáil leis an bhfreastalaí seo atá sé.",
"bundle_column_error.network.title": "Earráid líonra", "bundle_column_error.network.title": "Earráid líonra",
"bundle_column_error.retry": "Bain triail as arís", "bundle_column_error.retry": "Bain triail as arís",
@ -135,9 +135,9 @@
"column_header.hide_settings": "Folaigh socruithe", "column_header.hide_settings": "Folaigh socruithe",
"column_header.moveLeft_settings": "Bog an colún ar chlé", "column_header.moveLeft_settings": "Bog an colún ar chlé",
"column_header.moveRight_settings": "Bog an colún ar dheis", "column_header.moveRight_settings": "Bog an colún ar dheis",
"column_header.pin": "Greamaigh", "column_header.pin": "Pionna",
"column_header.show_settings": "Taispeáin socruithe", "column_header.show_settings": "Taispeáin socruithe",
"column_header.unpin": "Díghreamaigh", "column_header.unpin": "Bain pionna",
"column_subheading.settings": "Socruithe", "column_subheading.settings": "Socruithe",
"community.column_settings.local_only": "Áitiúil amháin", "community.column_settings.local_only": "Áitiúil amháin",
"community.column_settings.media_only": "Meáin Amháin", "community.column_settings.media_only": "Meáin Amháin",
@ -161,7 +161,7 @@
"compose_form.poll.switch_to_single": "Athraigh suirbhé chun cead a thabhairt do rogha amháin", "compose_form.poll.switch_to_single": "Athraigh suirbhé chun cead a thabhairt do rogha amháin",
"compose_form.poll.type": "Stíl", "compose_form.poll.type": "Stíl",
"compose_form.publish": "Postáil", "compose_form.publish": "Postáil",
"compose_form.publish_form": "Foilsigh\n", "compose_form.publish_form": "Post nua",
"compose_form.reply": "Freagra", "compose_form.reply": "Freagra",
"compose_form.save_changes": "Nuashonrú", "compose_form.save_changes": "Nuashonrú",
"compose_form.spoiler.marked": "Bain rabhadh ábhair", "compose_form.spoiler.marked": "Bain rabhadh ábhair",
@ -291,7 +291,7 @@
"filter_modal.added.short_explanation": "Cuireadh an postáil seo leis an gcatagóir scagaire seo a leanas: {title}.", "filter_modal.added.short_explanation": "Cuireadh an postáil seo leis an gcatagóir scagaire seo a leanas: {title}.",
"filter_modal.added.title": "Scagaire curtha leis!", "filter_modal.added.title": "Scagaire curtha leis!",
"filter_modal.select_filter.context_mismatch": "ní bhaineann sé leis an gcomhthéacs seo", "filter_modal.select_filter.context_mismatch": "ní bhaineann sé leis an gcomhthéacs seo",
"filter_modal.select_filter.expired": "as feidhm", "filter_modal.select_filter.expired": "imithe in éag",
"filter_modal.select_filter.prompt_new": "Catagóir nua: {name}", "filter_modal.select_filter.prompt_new": "Catagóir nua: {name}",
"filter_modal.select_filter.search": "Cuardaigh nó cruthaigh", "filter_modal.select_filter.search": "Cuardaigh nó cruthaigh",
"filter_modal.select_filter.subtitle": "Bain úsáid as catagóir reatha nó cruthaigh ceann nua", "filter_modal.select_filter.subtitle": "Bain úsáid as catagóir reatha nó cruthaigh ceann nua",
@ -377,7 +377,7 @@
"keyboard_shortcuts.boost": "Treisigh postáil", "keyboard_shortcuts.boost": "Treisigh postáil",
"keyboard_shortcuts.column": "to focus a status in one of the columns", "keyboard_shortcuts.column": "to focus a status in one of the columns",
"keyboard_shortcuts.compose": "to focus the compose textarea", "keyboard_shortcuts.compose": "to focus the compose textarea",
"keyboard_shortcuts.description": "Cuntas", "keyboard_shortcuts.description": "Cur síos",
"keyboard_shortcuts.direct": "to open direct messages column", "keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "Bog síos ar an liosta", "keyboard_shortcuts.down": "Bog síos ar an liosta",
"keyboard_shortcuts.enter": "Oscail postáil", "keyboard_shortcuts.enter": "Oscail postáil",
@ -394,17 +394,17 @@
"keyboard_shortcuts.my_profile": "Oscail do phróifíl", "keyboard_shortcuts.my_profile": "Oscail do phróifíl",
"keyboard_shortcuts.notifications": "to open notifications column", "keyboard_shortcuts.notifications": "to open notifications column",
"keyboard_shortcuts.open_media": "Oscail meáin", "keyboard_shortcuts.open_media": "Oscail meáin",
"keyboard_shortcuts.pinned": "to open pinned posts list", "keyboard_shortcuts.pinned": "Oscail liosta postálacha pinn",
"keyboard_shortcuts.profile": "Oscail próifíl an t-údar", "keyboard_shortcuts.profile": "Oscail próifíl an t-údar",
"keyboard_shortcuts.reply": "Freagair ar phostáil", "keyboard_shortcuts.reply": "Freagair ar phostáil",
"keyboard_shortcuts.requests": "Oscail liosta iarratas leanúnaí", "keyboard_shortcuts.requests": "Oscail liosta iarratas leanúnaí",
"keyboard_shortcuts.search": "to focus search", "keyboard_shortcuts.search": "Díriú ar an mbosca cuardaigh",
"keyboard_shortcuts.spoilers": "to show/hide CW field", "keyboard_shortcuts.spoilers": "Taispeáin / folaigh réimse CW",
"keyboard_shortcuts.start": "to open \"get started\" column", "keyboard_shortcuts.start": "Oscail an colún “tosaigh”",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW", "keyboard_shortcuts.toggle_hidden": "Taispeáin/folaigh an téacs taobh thiar de CW",
"keyboard_shortcuts.toggle_sensitivity": "Taispeáin / cuir i bhfolach meáin", "keyboard_shortcuts.toggle_sensitivity": "Taispeáin / cuir i bhfolach meáin",
"keyboard_shortcuts.toot": "Cuir tús le postáil nua", "keyboard_shortcuts.toot": "Cuir tús le postáil nua",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", "keyboard_shortcuts.unfocus": "Unfocus cum textarea/search",
"keyboard_shortcuts.up": "Bog suas ar an liosta", "keyboard_shortcuts.up": "Bog suas ar an liosta",
"lightbox.close": "Dún", "lightbox.close": "Dún",
"lightbox.compress": "Comhbhrúigh an bosca amhairc íomhá", "lightbox.compress": "Comhbhrúigh an bosca amhairc íomhá",
@ -545,12 +545,12 @@
"notifications_permission_banner.title": "Ná caill aon rud go deo", "notifications_permission_banner.title": "Ná caill aon rud go deo",
"onboarding.action.back": "Tóg ar ais mé", "onboarding.action.back": "Tóg ar ais mé",
"onboarding.actions.back": "Tóg ar ais mé", "onboarding.actions.back": "Tóg ar ais mé",
"onboarding.actions.go_to_explore": "See what's trending", "onboarding.actions.go_to_explore": "Tóg mé chun trending",
"onboarding.actions.go_to_home": "Go to your home feed", "onboarding.actions.go_to_home": "Tóg go dtí mo bheathú baile mé",
"onboarding.compose.template": "Dia duit #Mastodon!", "onboarding.compose.template": "Dia duit #Mastodon!",
"onboarding.follows.empty": "Ar an drochuair, ní féidir aon torthaí a thaispeáint faoi láthair. Is féidir leat triail a bhaint as cuardach nó brabhsáil ar an leathanach taiscéalaíochta chun teacht ar dhaoine le leanúint, nó bain triail eile as níos déanaí.", "onboarding.follows.empty": "Ar an drochuair, ní féidir aon torthaí a thaispeáint faoi láthair. Is féidir leat triail a bhaint as cuardach nó brabhsáil ar an leathanach taiscéalaíochta chun teacht ar dhaoine le leanúint, nó bain triail eile as níos déanaí.",
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!", "onboarding.follows.lead": "Is é do bheathú baile an príomhbhealach chun taithí a fháil ar Mastodon. Dá mhéad daoine a leanann tú, is ea is gníomhaí agus is suimiúla a bheidh sé. Chun tú a chur ar bun, seo roinnt moltaí:",
"onboarding.follows.title": "Popular on Mastodon", "onboarding.follows.title": "Cuir do chuid fotha baile in oiriúint duit féin",
"onboarding.profile.discoverable": "Déan mo phróifíl a fháil amach", "onboarding.profile.discoverable": "Déan mo phróifíl a fháil amach",
"onboarding.profile.discoverable_hint": "Nuair a roghnaíonn tú infhionnachtana ar Mastodon, dfhéadfadh do phoist a bheith le feiceáil i dtorthaí cuardaigh agus treochtaí, agus dfhéadfaí do phróifíl a mholadh do dhaoine a bhfuil na leasanna céanna acu leat.", "onboarding.profile.discoverable_hint": "Nuair a roghnaíonn tú infhionnachtana ar Mastodon, dfhéadfadh do phoist a bheith le feiceáil i dtorthaí cuardaigh agus treochtaí, agus dfhéadfaí do phróifíl a mholadh do dhaoine a bhfuil na leasanna céanna acu leat.",
"onboarding.profile.display_name": "Ainm taispeána", "onboarding.profile.display_name": "Ainm taispeána",
@ -566,17 +566,17 @@
"onboarding.share.message": "Is {username} mé ar #Mastodon! Tar lean mé ag {url}", "onboarding.share.message": "Is {username} mé ar #Mastodon! Tar lean mé ag {url}",
"onboarding.share.next_steps": "Na chéad chéimeanna eile is féidir:", "onboarding.share.next_steps": "Na chéad chéimeanna eile is féidir:",
"onboarding.share.title": "Roinn do phróifíl", "onboarding.share.title": "Roinn do phróifíl",
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", "onboarding.start.lead": "Tá tú mar chuid de Mastodon anois, ardán meán sóisialta díláraithe uathúil ina ndéanann tú - ní algartam - do thaithí féin a choimeád. Cuirimis tús leat ar an teorainn shóisialta nua seo:",
"onboarding.start.skip": "Want to skip right ahead?", "onboarding.start.skip": "Nach bhfuil cabhair uait le tosú?",
"onboarding.start.title": "Tá sé déanta agat!", "onboarding.start.title": "Tá sé déanta agat!",
"onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.", "onboarding.steps.follow_people.body": "Is éard atá i gceist le daoine suimiúla a leanúint ná Mastodon.",
"onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}", "onboarding.steps.follow_people.title": "Cuir do chuid fotha baile in oiriúint duit féin",
"onboarding.steps.publish_status.body": "Say hello to the world.", "onboarding.steps.publish_status.body": "Abair heileo leis an domhan le téacs, grianghraif, físeáin nó pobalbhreith {emoji}",
"onboarding.steps.publish_status.title": "Déan do chéad phostáil", "onboarding.steps.publish_status.title": "Déan do chéad phostáil",
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", "onboarding.steps.setup_profile.body": "Cuir le d'idirghníomhaíochtaí trí phróifíl chuimsitheach a bheith agat.",
"onboarding.steps.setup_profile.title": "Customize your profile", "onboarding.steps.setup_profile.title": "Déan do phróifíl a phearsantú",
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!", "onboarding.steps.share_profile.body": "Cuir in iúl do do chairde conas tú a aimsiú ar Mastodon",
"onboarding.steps.share_profile.title": "Share your profile", "onboarding.steps.share_profile.title": "Roinn do phróifíl Mastodon",
"onboarding.tips.2fa": "<strong>An raibh a fhios agat?</strong> Is féidir leat do chuntas a dhéanamh slán trí fhíordheimhniú dhá fhachtóir a shocrú i socruithe do chuntais. Oibríonn sé le haon aip TOTP de do rogha féin, níl aon uimhir theileafóin riachtanach!", "onboarding.tips.2fa": "<strong>An raibh a fhios agat?</strong> Is féidir leat do chuntas a dhéanamh slán trí fhíordheimhniú dhá fhachtóir a shocrú i socruithe do chuntais. Oibríonn sé le haon aip TOTP de do rogha féin, níl aon uimhir theileafóin riachtanach!",
"onboarding.tips.accounts_from_other_servers": "<strong>An raibh a fhios agat?</strong> Ós rud é go bhfuil Mastodon díláraithe, déanfar roinnt próifílí a dtagann tú trasna orthu a óstáil ar fhreastalaithe seachas do fhreastalaithe. Agus fós is féidir leat idirghníomhú leo gan uaim! Tá an freastalaí acu sa dara leath dá n-ainm úsáideora!", "onboarding.tips.accounts_from_other_servers": "<strong>An raibh a fhios agat?</strong> Ós rud é go bhfuil Mastodon díláraithe, déanfar roinnt próifílí a dtagann tú trasna orthu a óstáil ar fhreastalaithe seachas do fhreastalaithe. Agus fós is féidir leat idirghníomhú leo gan uaim! Tá an freastalaí acu sa dara leath dá n-ainm úsáideora!",
"onboarding.tips.migration": "<strong>An raibh a fhios agat?</strong> Más dóigh leat nach rogha freastalaí iontach é {domain} amach anseo, is féidir leat bogadh go freastalaí Mastodon eile gan do leantóirí a chailliúint. Is féidir leat do fhreastalaí féin a óstáil fiú!", "onboarding.tips.migration": "<strong>An raibh a fhios agat?</strong> Más dóigh leat nach rogha freastalaí iontach é {domain} amach anseo, is féidir leat bogadh go freastalaí Mastodon eile gan do leantóirí a chailliúint. Is féidir leat do fhreastalaí féin a óstáil fiú!",
@ -594,7 +594,7 @@
"poll.votes": "{votes, plural, one {# vóta} other {# vóta}}", "poll.votes": "{votes, plural, one {# vóta} other {# vóta}}",
"poll_button.add_poll": "Cruthaigh suirbhé", "poll_button.add_poll": "Cruthaigh suirbhé",
"poll_button.remove_poll": "Bain suirbhé", "poll_button.remove_poll": "Bain suirbhé",
"privacy.change": "Adjust status privacy", "privacy.change": "Athraigh príobháideacht postála",
"privacy.direct.long": "Luaigh gach duine sa phost", "privacy.direct.long": "Luaigh gach duine sa phost",
"privacy.direct.short": "Daoine ar leith", "privacy.direct.short": "Daoine ar leith",
"privacy.private.long": "Do leanúna amháin", "privacy.private.long": "Do leanúna amháin",
@ -687,8 +687,8 @@
"search_popout.specific_date": "dáta ar leith", "search_popout.specific_date": "dáta ar leith",
"search_popout.user": "úsáideoir", "search_popout.user": "úsáideoir",
"search_results.accounts": "Próifílí", "search_results.accounts": "Próifílí",
"search_results.all": "Uile", "search_results.all": "Gach",
"search_results.hashtags": "Haischlibeanna", "search_results.hashtags": "Haischlib",
"search_results.nothing_found": "Níorbh fhéidir aon rud a aimsiú do na téarmaí cuardaigh seo", "search_results.nothing_found": "Níorbh fhéidir aon rud a aimsiú do na téarmaí cuardaigh seo",
"search_results.see_all": "Gach rud a fheicáil", "search_results.see_all": "Gach rud a fheicáil",
"search_results.statuses": "Postálacha", "search_results.statuses": "Postálacha",
@ -705,12 +705,12 @@
"sign_in_banner.sso_redirect": "Logáil isteach nó Cláraigh", "sign_in_banner.sso_redirect": "Logáil isteach nó Cláraigh",
"status.admin_account": "Oscail comhéadan modhnóireachta do @{name}", "status.admin_account": "Oscail comhéadan modhnóireachta do @{name}",
"status.admin_domain": "Oscail comhéadan modhnóireachta le haghaidh {domain}", "status.admin_domain": "Oscail comhéadan modhnóireachta le haghaidh {domain}",
"status.admin_status": "Open this status in the moderation interface", "status.admin_status": "Oscail an postáil seo sa chomhéadan modhnóireachta",
"status.block": "Bac @{name}", "status.block": "Bac @{name}",
"status.bookmark": "Leabharmharcanna", "status.bookmark": "Leabharmharcanna",
"status.cancel_reblog_private": "Dímhol", "status.cancel_reblog_private": "Dímhol",
"status.cannot_reblog": "Ní féidir an phostáil seo a mholadh", "status.cannot_reblog": "Ní féidir an phostáil seo a mholadh",
"status.copy": "Copy link to status", "status.copy": "Cóipeáil an nasc chuig an bpostáil",
"status.delete": "Scrios", "status.delete": "Scrios",
"status.detailed_status": "Amharc comhrá mionsonraithe", "status.detailed_status": "Amharc comhrá mionsonraithe",
"status.direct": "Luaigh @{name} go príobháideach", "status.direct": "Luaigh @{name} go príobháideach",
@ -734,11 +734,11 @@
"status.more": "Tuilleadh", "status.more": "Tuilleadh",
"status.mute": "Balbhaigh @{name}", "status.mute": "Balbhaigh @{name}",
"status.mute_conversation": "Balbhaigh comhrá", "status.mute_conversation": "Balbhaigh comhrá",
"status.open": "Expand this status", "status.open": "Leathnaigh an post seo",
"status.pin": "Pionnáil ar do phróifíl", "status.pin": "Pionnáil ar do phróifíl",
"status.pinned": "Postáil pionnáilte", "status.pinned": "Postáil pionnáilte",
"status.read_more": "Léan a thuilleadh", "status.read_more": "Léan a thuilleadh",
"status.reblog": "Mol", "status.reblog": "Treisiú",
"status.reblog_private": "Mol le léargas bunúsach", "status.reblog_private": "Mol le léargas bunúsach",
"status.reblogged_by": "Mhol {name}", "status.reblogged_by": "Mhol {name}",
"status.reblogs": "{count, plural, one {buaic} other {buaic}}", "status.reblogs": "{count, plural, one {buaic} other {buaic}}",
@ -757,7 +757,7 @@
"status.show_more": "Taispeáin níos mó", "status.show_more": "Taispeáin níos mó",
"status.show_more_all": "Taispeáin níos mó d'uile", "status.show_more_all": "Taispeáin níos mó d'uile",
"status.show_original": "Taispeáin bunchóip", "status.show_original": "Taispeáin bunchóip",
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}", "status.title.with_attachments": "{user} a sheol {attachmentCount, plural, one {ceangal} two {{attachmentCount} ceangal} few {{attachmentCount} ceangail} many {{attachmentCount} ceangal} other {{attachmentCount} ceangal}}",
"status.translate": "Aistrigh", "status.translate": "Aistrigh",
"status.translated_from_with": "D'Aistrigh ón {lang} ag úsáid {provider}", "status.translated_from_with": "D'Aistrigh ón {lang} ag úsáid {provider}",
"status.uncached_media_warning": "Níl an réamhamharc ar fáil", "status.uncached_media_warning": "Níl an réamhamharc ar fáil",

View File

@ -28,7 +28,7 @@
"account.featured_tags.last_status_at": "Tasuffeɣt taneggarut ass n {date}", "account.featured_tags.last_status_at": "Tasuffeɣt taneggarut ass n {date}",
"account.featured_tags.last_status_never": "Ulac tisuffaɣ", "account.featured_tags.last_status_never": "Ulac tisuffaɣ",
"account.follow": "Ḍfer", "account.follow": "Ḍfer",
"account.follow_back": "Ḍfer-it ula d kečč·m", "account.follow_back": "Ḍfer-it ula d kečč·mm",
"account.followers": "Imeḍfaren", "account.followers": "Imeḍfaren",
"account.followers.empty": "Ar tura, ulac yiwen i yeṭṭafaṛen amseqdac-agi.", "account.followers.empty": "Ar tura, ulac yiwen i yeṭṭafaṛen amseqdac-agi.",
"account.followers_counter": "{count, plural, one {{counter} n umḍfar} other {{counter} n yimeḍfaren}}", "account.followers_counter": "{count, plural, one {{counter} n umḍfar} other {{counter} n yimeḍfaren}}",
@ -38,6 +38,7 @@
"account.go_to_profile": "Ddu ɣer umaɣnu", "account.go_to_profile": "Ddu ɣer umaɣnu",
"account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}", "account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
"account.joined_short": "Izeddi da seg ass n", "account.joined_short": "Izeddi da seg ass n",
"account.languages": "Beddel tutlayin yettwajerden",
"account.link_verified_on": "Taɣara n useɣwen-a tettwasenqed ass n {date}", "account.link_verified_on": "Taɣara n useɣwen-a tettwasenqed ass n {date}",
"account.locked_info": "Amiḍan-agi uslig isekweṛ. D bab-is kan i izemren ad yeǧǧ, s ufus-is, win ara t-iḍefṛen.", "account.locked_info": "Amiḍan-agi uslig isekweṛ. D bab-is kan i izemren ad yeǧǧ, s ufus-is, win ara t-iḍefṛen.",
"account.media": "Timidyatin", "account.media": "Timidyatin",
@ -235,6 +236,7 @@
"follow_request.authorize": "Ssireg", "follow_request.authorize": "Ssireg",
"follow_request.reject": "Agi", "follow_request.reject": "Agi",
"follow_suggestions.dismiss": "Dayen ur t-id-skan ara", "follow_suggestions.dismiss": "Dayen ur t-id-skan ara",
"follow_suggestions.popular_suggestion_longer": "Yettwassen deg {domain}",
"follow_suggestions.view_all": "Wali-ten akk", "follow_suggestions.view_all": "Wali-ten akk",
"follow_suggestions.who_to_follow": "Ad tḍefreḍ?", "follow_suggestions.who_to_follow": "Ad tḍefreḍ?",
"followed_tags": "Ihacṭagen yettwaḍfaren", "followed_tags": "Ihacṭagen yettwaḍfaren",

View File

@ -0,0 +1,207 @@
import type {
ApiAccountRelationshipSeveranceEventJSON,
ApiAccountWarningJSON,
BaseNotificationGroupJSON,
ApiNotificationGroupJSON,
ApiNotificationJSON,
NotificationType,
NotificationWithStatusType,
} from 'mastodon/api_types/notifications';
import type { ApiReportJSON } from 'mastodon/api_types/reports';
// Maximum number of avatars displayed in a notification group
// This corresponds to the max lenght of `group.sampleAccountIds`
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
interface BaseNotificationGroup
extends Omit<BaseNotificationGroupJSON, 'sample_accounts'> {
sampleAccountIds: string[];
}
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
extends BaseNotificationGroup {
type: Type;
statusId: string;
}
interface BaseNotification<Type extends NotificationType>
extends BaseNotificationGroup {
type: Type;
}
export type NotificationGroupFavourite =
BaseNotificationWithStatus<'favourite'>;
export type NotificationGroupReblog = BaseNotificationWithStatus<'reblog'>;
export type NotificationGroupStatus = BaseNotificationWithStatus<'status'>;
export type NotificationGroupMention = BaseNotificationWithStatus<'mention'>;
export type NotificationGroupPoll = BaseNotificationWithStatus<'poll'>;
export type NotificationGroupUpdate = BaseNotificationWithStatus<'update'>;
export type NotificationGroupFollow = BaseNotification<'follow'>;
export type NotificationGroupFollowRequest = BaseNotification<'follow_request'>;
export type NotificationGroupAdminSignUp = BaseNotification<'admin.sign_up'>;
export type AccountWarningAction =
| 'none'
| 'disable'
| 'mark_statuses_as_sensitive'
| 'delete_statuses'
| 'sensitive'
| 'silence'
| 'suspend';
export interface AccountWarning
extends Omit<ApiAccountWarningJSON, 'target_account'> {
targetAccountId: string;
}
export interface NotificationGroupModerationWarning
extends BaseNotification<'moderation_warning'> {
moderationWarning: AccountWarning;
}
type AccountRelationshipSeveranceEvent =
ApiAccountRelationshipSeveranceEventJSON;
export interface NotificationGroupSeveredRelationships
extends BaseNotification<'severed_relationships'> {
event: AccountRelationshipSeveranceEvent;
}
interface Report extends Omit<ApiReportJSON, 'target_account'> {
targetAccountId: string;
}
export interface NotificationGroupAdminReport
extends BaseNotification<'admin.report'> {
report: Report;
}
export type NotificationGroup =
| NotificationGroupFavourite
| NotificationGroupReblog
| NotificationGroupStatus
| NotificationGroupMention
| NotificationGroupPoll
| NotificationGroupUpdate
| NotificationGroupFollow
| NotificationGroupFollowRequest
| NotificationGroupModerationWarning
| NotificationGroupSeveredRelationships
| NotificationGroupAdminSignUp
| NotificationGroupAdminReport;
function createReportFromJSON(reportJSON: ApiReportJSON): Report {
const { target_account, ...report } = reportJSON;
return {
targetAccountId: target_account.id,
...report,
};
}
function createAccountWarningFromJSON(
warningJSON: ApiAccountWarningJSON,
): AccountWarning {
const { target_account, ...warning } = warningJSON;
return {
targetAccountId: target_account.id,
...warning,
};
}
function createAccountRelationshipSeveranceEventFromJSON(
eventJson: ApiAccountRelationshipSeveranceEventJSON,
): AccountRelationshipSeveranceEvent {
return eventJson;
}
export function createNotificationGroupFromJSON(
groupJson: ApiNotificationGroupJSON,
): NotificationGroup {
const { sample_accounts, ...group } = groupJson;
const sampleAccountIds = sample_accounts.map((account) => account.id);
switch (group.type) {
case 'favourite':
case 'reblog':
case 'status':
case 'mention':
case 'poll':
case 'update': {
const { status, ...groupWithoutStatus } = group;
return {
statusId: status.id,
sampleAccountIds,
...groupWithoutStatus,
};
}
case 'admin.report': {
const { report, ...groupWithoutTargetAccount } = group;
return {
report: createReportFromJSON(report),
sampleAccountIds,
...groupWithoutTargetAccount,
};
}
case 'severed_relationships':
return {
...group,
event: createAccountRelationshipSeveranceEventFromJSON(group.event),
sampleAccountIds,
};
case 'moderation_warning': {
const { moderation_warning, ...groupWithoutModerationWarning } = group;
return {
...groupWithoutModerationWarning,
moderationWarning: createAccountWarningFromJSON(moderation_warning),
sampleAccountIds,
};
}
default:
return {
sampleAccountIds,
...group,
};
}
}
export function createNotificationGroupFromNotificationJSON(
notification: ApiNotificationJSON,
) {
const group = {
sampleAccountIds: [notification.account.id],
group_key: notification.group_key,
notifications_count: 1,
type: notification.type,
most_recent_notification_id: notification.id,
page_min_id: notification.id,
page_max_id: notification.id,
latest_page_notification_at: notification.created_at,
} as NotificationGroup;
switch (notification.type) {
case 'favourite':
case 'reblog':
case 'status':
case 'mention':
case 'poll':
case 'update':
return { ...group, statusId: notification.status.id };
case 'admin.report':
return { ...group, report: createReportFromJSON(notification.report) };
case 'severed_relationships':
return {
...group,
event: createAccountRelationshipSeveranceEventFromJSON(
notification.event,
),
};
case 'moderation_warning':
return {
...group,
moderationWarning: createAccountWarningFromJSON(
notification.moderation_warning,
),
};
default:
return group;
}
}

View File

@ -24,6 +24,7 @@ import { markersReducer } from './markers';
import media_attachments from './media_attachments'; import media_attachments from './media_attachments';
import meta from './meta'; import meta from './meta';
import { modalReducer } from './modal'; import { modalReducer } from './modal';
import { notificationGroupsReducer } from './notification_groups';
import { notificationPolicyReducer } from './notification_policy'; import { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests'; import { notificationRequestsReducer } from './notification_requests';
import notifications from './notifications'; import notifications from './notifications';
@ -65,6 +66,7 @@ const reducers = {
search, search,
media_attachments, media_attachments,
notifications, notifications,
notificationGroups: notificationGroupsReducer,
height_cache, height_cache,
custom_emojis, custom_emojis,
lists, lists,

View File

@ -1,6 +1,7 @@
import { createReducer } from '@reduxjs/toolkit'; import { createReducer } from '@reduxjs/toolkit';
import { submitMarkersAction } from 'mastodon/actions/markers'; import { submitMarkersAction, fetchMarkers } from 'mastodon/actions/markers';
import { compareId } from 'mastodon/compare_id';
const initialState = { const initialState = {
home: '0', home: '0',
@ -15,4 +16,23 @@ export const markersReducer = createReducer(initialState, (builder) => {
if (notifications) state.notifications = notifications; if (notifications) state.notifications = notifications;
}, },
); );
builder.addCase(
fetchMarkers.fulfilled,
(
state,
{
payload: {
markers: { home, notifications },
},
},
) => {
if (home && compareId(home.last_read_id, state.home) > 0)
state.home = home.last_read_id;
if (
notifications &&
compareId(notifications.last_read_id, state.notifications) > 0
)
state.notifications = notifications.last_read_id;
},
);
}); });

View File

@ -0,0 +1,508 @@
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import {
authorizeFollowRequestSuccess,
blockAccountSuccess,
muteAccountSuccess,
rejectFollowRequestSuccess,
} from 'mastodon/actions/accounts_typed';
import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks_typed';
import { fetchMarkers } from 'mastodon/actions/markers';
import {
clearNotifications,
fetchNotifications,
fetchNotificationsGap,
processNewNotificationForGroups,
loadPending,
updateScrollPosition,
markNotificationsAsRead,
mountNotifications,
unmountNotifications,
} from 'mastodon/actions/notification_groups';
import {
disconnectTimeline,
timelineDelete,
} from 'mastodon/actions/timelines_typed';
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems } from 'mastodon/initial_state';
import {
NOTIFICATIONS_GROUP_MAX_AVATARS,
createNotificationGroupFromJSON,
createNotificationGroupFromNotificationJSON,
} from 'mastodon/models/notification_group';
import type { NotificationGroup } from 'mastodon/models/notification_group';
const NOTIFICATIONS_TRIM_LIMIT = 50;
export interface NotificationGap {
type: 'gap';
maxId?: string;
sinceId?: string;
}
interface NotificationGroupsState {
groups: (NotificationGroup | NotificationGap)[];
pendingGroups: (NotificationGroup | NotificationGap)[];
scrolledToTop: boolean;
isLoading: boolean;
lastReadId: string;
mounted: number;
isTabVisible: boolean;
}
const initialState: NotificationGroupsState = {
groups: [],
pendingGroups: [], // holds pending groups in slow mode
scrolledToTop: false,
isLoading: false,
// The following properties are used to track unread notifications
lastReadId: '0', // used for unread notifications
mounted: 0, // number of mounted notification list components, usually 0 or 1
isTabVisible: true,
};
function filterNotificationsForAccounts(
groups: NotificationGroupsState['groups'],
accountIds: string[],
onlyForType?: string,
) {
groups = groups
.map((group) => {
if (
group.type !== 'gap' &&
(!onlyForType || group.type === onlyForType)
) {
const previousLength = group.sampleAccountIds.length;
group.sampleAccountIds = group.sampleAccountIds.filter(
(id) => !accountIds.includes(id),
);
const newLength = group.sampleAccountIds.length;
const removed = previousLength - newLength;
group.notifications_count -= removed;
}
return group;
})
.filter(
(group) => group.type === 'gap' || group.sampleAccountIds.length > 0,
);
mergeGaps(groups);
return groups;
}
function filterNotificationsForStatus(
groups: NotificationGroupsState['groups'],
statusId: string,
) {
groups = groups.filter(
(group) =>
group.type === 'gap' ||
!('statusId' in group) ||
group.statusId !== statusId,
);
mergeGaps(groups);
return groups;
}
function removeNotificationsForAccounts(
state: NotificationGroupsState,
accountIds: string[],
onlyForType?: string,
) {
state.groups = filterNotificationsForAccounts(
state.groups,
accountIds,
onlyForType,
);
state.pendingGroups = filterNotificationsForAccounts(
state.pendingGroups,
accountIds,
onlyForType,
);
}
function removeNotificationsForStatus(
state: NotificationGroupsState,
statusId: string,
) {
state.groups = filterNotificationsForStatus(state.groups, statusId);
state.pendingGroups = filterNotificationsForStatus(
state.pendingGroups,
statusId,
);
}
function isNotificationGroup(
groupOrGap: NotificationGroup | NotificationGap,
): groupOrGap is NotificationGroup {
return groupOrGap.type !== 'gap';
}
// Merge adjacent gaps in `groups` in-place
function mergeGaps(groups: NotificationGroupsState['groups']) {
for (let i = 0; i < groups.length; i++) {
const firstGroupOrGap = groups[i];
if (firstGroupOrGap?.type === 'gap') {
let lastGap = firstGroupOrGap;
let j = i + 1;
for (; j < groups.length; j++) {
const groupOrGap = groups[j];
if (groupOrGap?.type === 'gap') lastGap = groupOrGap;
else break;
}
if (j - i > 1) {
groups.splice(i, j - i, {
type: 'gap',
maxId: firstGroupOrGap.maxId,
sinceId: lastGap.sinceId,
});
}
}
}
}
// Checks if `groups[index-1]` and `groups[index]` are gaps, and merge them in-place if they are
function mergeGapsAround(
groups: NotificationGroupsState['groups'],
index: number,
) {
if (index > 0) {
const potentialFirstGap = groups[index - 1];
const potentialSecondGap = groups[index];
if (
potentialFirstGap?.type === 'gap' &&
potentialSecondGap?.type === 'gap'
) {
groups.splice(index - 1, 2, {
type: 'gap',
maxId: potentialFirstGap.maxId,
sinceId: potentialSecondGap.sinceId,
});
}
}
}
function processNewNotification(
groups: NotificationGroupsState['groups'],
notification: ApiNotificationJSON,
) {
const existingGroupIndex = groups.findIndex(
(group) =>
group.type !== 'gap' && group.group_key === notification.group_key,
);
// In any case, we are going to add a group at the top
// If there is currently a gap at the top, now is the time to update it
if (groups.length > 0 && groups[0]?.type === 'gap') {
groups[0].maxId = notification.id;
}
if (existingGroupIndex > -1) {
const existingGroup = groups[existingGroupIndex];
if (
existingGroup &&
existingGroup.type !== 'gap' &&
!existingGroup.sampleAccountIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post
) {
// Update the existing group
if (
existingGroup.sampleAccountIds.unshift(notification.account.id) >
NOTIFICATIONS_GROUP_MAX_AVATARS
)
existingGroup.sampleAccountIds.pop();
existingGroup.most_recent_notification_id = notification.id;
existingGroup.page_max_id = notification.id;
existingGroup.latest_page_notification_at = notification.created_at;
existingGroup.notifications_count += 1;
groups.splice(existingGroupIndex, 1);
mergeGapsAround(groups, existingGroupIndex);
groups.unshift(existingGroup);
}
} else {
// Create a new group
groups.unshift(createNotificationGroupFromNotificationJSON(notification));
}
}
function trimNotifications(state: NotificationGroupsState) {
if (state.scrolledToTop) {
state.groups.splice(NOTIFICATIONS_TRIM_LIMIT);
}
}
function shouldMarkNewNotificationsAsRead(
{
isTabVisible,
scrolledToTop,
mounted,
lastReadId,
groups,
}: NotificationGroupsState,
ignoreScroll = false,
) {
const isMounted = mounted > 0;
const oldestGroup = groups.findLast(isNotificationGroup);
const hasMore = groups.at(-1)?.type === 'gap';
const oldestGroupReached =
!hasMore ||
lastReadId === '0' ||
(oldestGroup?.page_min_id &&
compareId(oldestGroup.page_min_id, lastReadId) <= 0);
return (
isTabVisible &&
(ignoreScroll || scrolledToTop) &&
isMounted &&
oldestGroupReached
);
}
function updateLastReadId(
state: NotificationGroupsState,
group: NotificationGroup | undefined = undefined,
) {
if (shouldMarkNewNotificationsAsRead(state)) {
group = group ?? state.groups.find(isNotificationGroup);
if (
group?.page_max_id &&
compareId(state.lastReadId, group.page_max_id) < 0
)
state.lastReadId = group.page_max_id;
}
}
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
initialState,
(builder) => {
builder
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.groups = action.payload.map((json) =>
json.type === 'gap' ? json : createNotificationGroupFromJSON(json),
);
state.isLoading = false;
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,
);
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),
);
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);
})
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload;
processNewNotification(
usePendingItems ? state.pendingGroups : state.groups,
notification,
);
updateLastReadId(state);
trimNotifications(state);
})
.addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') {
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
state.groups.unshift({
type: 'gap',
sinceId: state.groups[0]?.page_min_id,
});
}
}
})
.addCase(timelineDelete, (state, action) => {
removeNotificationsForStatus(state, action.payload.statusId);
})
.addCase(clearNotifications.pending, (state) => {
state.groups = [];
state.pendingGroups = [];
})
.addCase(blockAccountSuccess, (state, action) => {
removeNotificationsForAccounts(state, [action.payload.relationship.id]);
})
.addCase(muteAccountSuccess, (state, action) => {
if (action.payload.relationship.muting_notifications)
removeNotificationsForAccounts(state, [
action.payload.relationship.id,
]);
})
.addCase(blockDomainSuccess, (state, action) => {
removeNotificationsForAccounts(
state,
action.payload.accounts.map((account) => account.id),
);
})
.addCase(loadPending, (state) => {
// First, remove any existing group and merge data
state.pendingGroups.forEach((group) => {
if (group.type !== 'gap') {
const existingGroupIndex = state.groups.findIndex(
(groupOrGap) =>
isNotificationGroup(groupOrGap) &&
groupOrGap.group_key === group.group_key,
);
if (existingGroupIndex > -1) {
const existingGroup = state.groups[existingGroupIndex];
if (existingGroup && existingGroup.type !== 'gap') {
group.notifications_count += existingGroup.notifications_count;
group.sampleAccountIds = group.sampleAccountIds
.concat(existingGroup.sampleAccountIds)
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
state.groups.splice(existingGroupIndex, 1);
}
}
}
trimNotifications(state);
});
// Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = [];
})
.addCase(updateScrollPosition, (state, action) => {
state.scrolledToTop = action.payload.top;
updateLastReadId(state);
trimNotifications(state);
})
.addCase(markNotificationsAsRead, (state) => {
const mostRecentGroup = state.groups.find(isNotificationGroup);
if (
mostRecentGroup?.page_max_id &&
compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0
)
state.lastReadId = mostRecentGroup.page_max_id;
})
.addCase(fetchMarkers.fulfilled, (state, action) => {
if (
action.payload.markers.notifications &&
compareId(
state.lastReadId,
action.payload.markers.notifications.last_read_id,
) < 0
)
state.lastReadId = action.payload.markers.notifications.last_read_id;
})
.addCase(mountNotifications, (state) => {
state.mounted += 1;
updateLastReadId(state);
})
.addCase(unmountNotifications, (state) => {
state.mounted -= 1;
})
.addCase(focusApp, (state) => {
state.isTabVisible = true;
updateLastReadId(state);
})
.addCase(unfocusApp, (state) => {
state.isTabVisible = false;
})
.addMatcher(
isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess),
(state, action) => {
removeNotificationsForAccounts(
state,
[action.payload.id],
'follow_request',
);
},
)
.addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
(state) => {
state.isLoading = true;
},
)
.addMatcher(
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected),
(state) => {
state.isLoading = false;
},
);
},
);

View File

@ -16,13 +16,13 @@ import {
import { import {
fetchMarkers, fetchMarkers,
} from '../actions/markers'; } from '../actions/markers';
import { clearNotifications } from '../actions/notification_groups';
import { import {
notificationsUpdate, notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_CLEAR,
NOTIFICATIONS_SCROLL_TOP, NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_LOAD_PENDING, NOTIFICATIONS_LOAD_PENDING,
NOTIFICATIONS_MOUNT, NOTIFICATIONS_MOUNT,
@ -290,7 +290,7 @@ export default function notifications(state = initialState, action) {
case authorizeFollowRequestSuccess.type: case authorizeFollowRequestSuccess.type:
case rejectFollowRequestSuccess.type: case rejectFollowRequestSuccess.type:
return filterNotifications(state, [action.payload.id], 'follow_request'); return filterNotifications(state, [action.payload.id], 'follow_request');
case NOTIFICATIONS_CLEAR: case clearNotifications.pending.type:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false); return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case timelineDelete.type: case timelineDelete.type:
return deleteByStatus(state, action.payload.statusId); return deleteByStatus(state, action.payload.statusId);

View File

@ -0,0 +1,34 @@
import { createSelector } from '@reduxjs/toolkit';
import { compareId } from 'mastodon/compare_id';
import type { RootState } from 'mastodon/store';
export const selectUnreadNotificationGroupsCount = createSelector(
[
(s: RootState) => s.notificationGroups.lastReadId,
(s: RootState) => s.notificationGroups.pendingGroups,
(s: RootState) => s.notificationGroups.groups,
],
(notificationMarker, pendingGroups, groups) => {
return (
groups.filter(
(group) =>
group.type !== 'gap' &&
group.page_max_id &&
compareId(group.page_max_id, notificationMarker) > 0,
).length +
pendingGroups.filter(
(group) =>
group.type !== 'gap' &&
group.page_max_id &&
compareId(group.page_max_id, notificationMarker) > 0,
).length
);
},
);
export const selectPendingNotificationGroupsCount = createSelector(
[(s: RootState) => s.notificationGroups.pendingGroups],
(pendingGroups) =>
pendingGroups.filter((group) => group.type !== 'gap').length,
);

View File

@ -0,0 +1,40 @@
import type { RootState } from 'mastodon/store';
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
export const selectSettingsNotificationsShows = (state: RootState) =>
state.settings.getIn(['notifications', 'shows']).toJS() as Record<
string,
boolean
>;
export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
Object.entries(selectSettingsNotificationsShows(state))
.filter(([_type, enabled]) => !enabled)
.map(([type, _enabled]) => type);
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
export const selectSettingsNotificationsQuickFilterActive = (
state: RootState,
) => state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
export const selectSettingsNotificationsQuickFilterAdvanced = (
state: RootState,
) =>
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
state.settings.getIn(['notifications', 'showUnread']) as boolean;
export const selectNeedsNotificationPermission = (state: RootState) =>
(state.settings.getIn(['notifications', 'alerts']).includes(true) &&
state.notifications.get('browserSupport') &&
state.notifications.get('browserPermission') === 'default' &&
!state.settings.getIn([
'notifications',
'dismissPermissionBanner',
])) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */

View File

@ -1611,14 +1611,19 @@ body > [data-popper-placement] {
} }
} }
.status__wrapper-direct { .status__wrapper-direct,
.notification-ungrouped--direct {
background: rgba($ui-highlight-color, 0.05); background: rgba($ui-highlight-color, 0.05);
&:focus { &:focus {
background: rgba($ui-highlight-color, 0.05); background: rgba($ui-highlight-color, 0.1);
} }
}
.status__prepend { .status__wrapper-direct,
.notification-ungrouped--direct {
.status__prepend,
.notification-ungrouped__header {
color: $highlight-text-color; color: $highlight-text-color;
} }
} }
@ -2209,41 +2214,28 @@ a.account__display-name {
} }
} }
.notification__relationships-severance-event, .notification-group--link {
.notification__moderation-warning {
display: flex;
gap: 16px;
color: $secondary-text-color; color: $secondary-text-color;
text-decoration: none; text-decoration: none;
align-items: flex-start;
padding: 16px 32px;
border-bottom: 1px solid var(--background-border-color);
&:hover { .notification-group__main {
color: $primary-text-color;
}
.icon {
padding: 2px;
color: $highlight-text-color;
}
&__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
flex-grow: 1; flex-grow: 1;
font-size: 16px; font-size: 15px;
line-height: 24px; line-height: 22px;
strong { strong,
bdi {
font-weight: 700; font-weight: 700;
} }
.link-button { .link-button {
font-size: inherit; font-size: inherit;
line-height: inherit; line-height: inherit;
font-weight: inherit;
} }
} }
} }
@ -10193,8 +10185,8 @@ noscript {
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid var(--background-border-color); border-bottom: 1px solid var(--background-border-color);
padding: 24px 32px; padding: 16px 24px;
gap: 16px; gap: 8px;
color: $darker-text-color; color: $darker-text-color;
text-decoration: none; text-decoration: none;
@ -10204,10 +10196,8 @@ noscript {
color: $secondary-text-color; color: $secondary-text-color;
} }
.icon { .notification-group__icon {
width: 24px; color: inherit;
height: 24px;
padding: 2px;
} }
&__text { &__text {
@ -10345,6 +10335,251 @@ noscript {
} }
} }
.notification-group {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 16px 24px;
border-bottom: 1px solid var(--background-border-color);
&__icon {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: $dark-text-color;
.icon {
width: 28px;
height: 28px;
}
}
&--follow &__icon,
&--follow-request &__icon {
color: $highlight-text-color;
}
&--favourite &__icon {
color: $gold-star;
}
&--reblog &__icon {
color: $valid-value-color;
}
&--relationships-severance-event &__icon,
&--admin-report &__icon,
&--admin-sign-up &__icon {
color: $dark-text-color;
}
&--moderation-warning &__icon {
color: $red-bookmark;
}
&--follow-request &__actions {
align-items: center;
display: flex;
gap: 8px;
.icon-button {
border: 1px solid var(--background-border-color);
border-radius: 50%;
padding: 1px;
}
}
&__main {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1 1 auto;
overflow: hidden;
&__header {
display: flex;
flex-direction: column;
gap: 8px;
&__wrapper {
display: flex;
justify-content: space-between;
}
&__label {
display: flex;
gap: 8px;
font-size: 15px;
line-height: 22px;
color: $darker-text-color;
a {
color: inherit;
text-decoration: none;
}
bdi {
font-weight: 700;
color: $primary-text-color;
}
time {
color: $dark-text-color;
}
}
}
&__status {
border: 1px solid var(--background-border-color);
border-radius: 8px;
padding: 8px;
}
}
&__avatar-group {
display: flex;
gap: 8px;
height: 28px;
overflow-y: hidden;
flex-wrap: wrap;
}
.status {
padding: 0;
border: 0;
}
&__embedded-status {
&__account {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
color: $dark-text-color;
bdi {
color: inherit;
}
}
.account__avatar {
opacity: 0.5;
}
&__content {
display: -webkit-box;
font-size: 15px;
line-height: 22px;
color: $dark-text-color;
cursor: pointer;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
max-height: 4 * 22px;
overflow: hidden;
p,
a {
color: inherit;
}
}
}
}
.notification-ungrouped {
padding: 16px 24px;
border-bottom: 1px solid var(--background-border-color);
&__header {
display: flex;
align-items: center;
gap: 8px;
color: $dark-text-color;
font-size: 15px;
line-height: 22px;
font-weight: 500;
padding-inline-start: 24px;
margin-bottom: 16px;
&__icon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
.icon {
width: 16px;
height: 16px;
}
}
a {
color: inherit;
text-decoration: none;
}
}
.status {
border: 0;
padding: 0;
&__avatar {
width: 40px;
height: 40px;
}
}
.status__wrapper-direct {
background: transparent;
}
$icon-margin: 48px; // 40px avatar + 8px gap
.status__content,
.status__action-bar,
.media-gallery,
.video-player,
.audio-player,
.attachment-list,
.picture-in-picture-placeholder,
.more-from-author,
.status-card,
.hashtag-bar {
margin-inline-start: $icon-margin;
width: calc(100% - $icon-margin);
}
.more-from-author {
width: calc(100% - $icon-margin + 2px);
}
.status__content__read-more-button {
margin-inline-start: $icon-margin;
}
.notification__report {
border: 0;
padding: 0;
}
}
.notification-group--unread,
.notification-ungrouped--unread {
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
border-inline-start: 4px solid $highlight-text-color;
pointer-events: none;
}
}
.hover-card-controller[data-popper-reference-hidden='true'] { .hover-card-controller[data-popper-reference-hidden='true'] {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;

View File

@ -30,6 +30,7 @@ class Notification < ApplicationRecord
'Poll' => :poll, 'Poll' => :poll,
}.freeze }.freeze
# Please update app/javascript/api_types/notification.ts if you change this
PROPERTIES = { PROPERTIES = {
mention: { mention: {
filterable: true, filterable: true,

View File

@ -3,13 +3,17 @@
class NotificationGroup < ActiveModelSerializers::Model class NotificationGroup < ActiveModelSerializers::Model
attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id attributes :group_key, :sample_accounts, :notifications_count, :notification, :most_recent_notification_id
# Try to keep this consistent with `app/javascript/mastodon/models/notification_group.ts`
SAMPLE_ACCOUNTS_SIZE = 8
def self.from_notification(notification, max_id: nil) def self.from_notification(notification, max_id: nil)
if notification.group_key.present? if notification.group_key.present?
# TODO: caching and preloading # TODO: caching, and, if caching, preloading
scope = notification.account.notifications.where(group_key: notification.group_key) scope = notification.account.notifications.where(group_key: notification.group_key)
scope = scope.where(id: ..max_id) if max_id.present? scope = scope.where(id: ..max_id) if max_id.present?
most_recent_notifications = scope.order(id: :desc).take(3) # Ideally, we would not load accounts for each notification group
most_recent_notifications = scope.order(id: :desc).includes(:from_account).take(SAMPLE_ACCOUNTS_SIZE)
most_recent_id = most_recent_notifications.first.id most_recent_id = most_recent_notifications.first.id
sample_accounts = most_recent_notifications.map(&:from_account) sample_accounts = most_recent_notifications.map(&:from_account)
notifications_count = scope.count notifications_count = scope.count

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::NotificationGroupSerializer < ActiveModel::Serializer class REST::NotificationGroupSerializer < ActiveModel::Serializer
# Please update app/javascript/api_types/notification.ts when making changes to the attributes
attributes :group_key, :notifications_count, :type, :most_recent_notification_id attributes :group_key, :notifications_count, :type, :most_recent_notification_id
attribute :page_min_id, if: :paginated? attribute :page_min_id, if: :paginated?

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::NotificationSerializer < ActiveModel::Serializer class REST::NotificationSerializer < ActiveModel::Serializer
# Please update app/javascript/api_types/notification.ts when making changes to the attributes
attributes :id, :type, :created_at, :group_key attributes :id, :type, :created_at, :group_key
attribute :filtered, if: :filtered? attribute :filtered, if: :filtered?

View File

@ -4,7 +4,6 @@ class NotifyService < BaseService
include Redisable include Redisable
MAXIMUM_GROUP_SPAN_HOURS = 12 MAXIMUM_GROUP_SPAN_HOURS = 12
MAXIMUM_GROUP_GAP_TIME = 4.hours.to_i
NON_EMAIL_TYPES = %i( NON_EMAIL_TYPES = %i(
admin.report admin.report
@ -217,9 +216,8 @@ class NotifyService < BaseService
previous_bucket = redis.get(redis_key).to_i previous_bucket = redis.get(redis_key).to_i
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
# Do not track groups past a given inactivity time
# We do not concern ourselves with race conditions since we use hour buckets # We do not concern ourselves with race conditions since we use hour buckets
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_GAP_TIME) redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS.hours.to_i)
"#{type_prefix}-#{hour_bucket}" "#{type_prefix}-#{hour_bucket}"
end end

View File

@ -226,6 +226,7 @@ bg:
update_custom_emoji: Обновяване на персонализираното емоджи update_custom_emoji: Обновяване на персонализираното емоджи
update_domain_block: Обновяване на блокирането за домейна update_domain_block: Обновяване на блокирането за домейна
update_ip_block: Обновяване на правило за IP update_ip_block: Обновяване на правило за IP
update_report: Обновяване на доклада
update_status: Обновяване на публикация update_status: Обновяване на публикация
update_user_role: Обновяване на ролята update_user_role: Обновяване на ролята
actions: actions:
@ -638,6 +639,7 @@ bg:
report: 'Докладване на #%{id}' report: 'Докладване на #%{id}'
reported_account: Докладван акаунт reported_account: Докладван акаунт
reported_by: Докладвано от reported_by: Докладвано от
reported_with_application: Докладвано с приложението
resolved: Разрешено resolved: Разрешено
resolved_msg: Успешно разрешен доклад! resolved_msg: Успешно разрешен доклад!
skip_to_actions: Прескок към действия skip_to_actions: Прескок към действия

View File

@ -116,3 +116,9 @@ ga:
expired: imithe in éag, iarr ceann nua le do thoil expired: imithe in éag, iarr ceann nua le do thoil
not_found: níor aimsíodh é not_found: níor aimsíodh é
not_locked: nach raibh faoi ghlas not_locked: nach raibh faoi ghlas
not_saved:
few: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
many: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
one: 'Chuir earráid 1 cosc ar an %{resource} seo a shábháil:'
other: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'
two: 'Chuir %{count} earráid cosc ar an %{resource} seo a shábháil:'

View File

@ -5,7 +5,17 @@ ga:
doorkeeper/application: doorkeeper/application:
name: Ainm feidhmchláir name: Ainm feidhmchláir
redirect_uri: Atreoraigh URI redirect_uri: Atreoraigh URI
scopes: Scóip
website: Suíomh gréasáin feidhmchláir website: Suíomh gréasáin feidhmchláir
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: ní féidir blúire a bheith ann.
invalid_uri: caithfidh gur URI bailí é.
relative_uri: a bheith ina URI iomlán.
secured_uri: caithfidh gur URI HTTPS/SSL é.
doorkeeper: doorkeeper:
applications: applications:
buttons: buttons:
@ -16,38 +26,172 @@ ga:
submit: Cuir isteach submit: Cuir isteach
confirmations: confirmations:
destroy: An bhfuil tú cinnte? destroy: An bhfuil tú cinnte?
edit:
title: Cuir feidhmchlár in eagar
form:
error: Úps! Seiceáil d'fhoirm le haghaidh earráidí féideartha
help:
native_redirect_uri: Úsáid %{native_redirect_uri} le haghaidh tástálacha logánta
redirect_uri: Úsáid líne amháin in aghaidh an URI
scopes: Scóipeanna ar leith le spásanna. Fág bán chun na scóip réamhshocraithe a úsáid.
index: index:
application: Ainm feidhmchláir application: Ainm feidhmchláir
callback_url: URL aisghlaoch
delete: Scrios delete: Scrios
empty: Níl aon iarratais agat.
name: Ainm name: Ainm
new: Feidhmchlár nua
scopes: Scóip
show: Taispeáin show: Taispeáin
title: D'iarratais
new:
title: Feidhmchlár nua
show: show:
actions: Gníomhartha
application_id: Eochair chliaint application_id: Eochair chliaint
callback_urls: URLanna aisghlaoch
scopes: Scóip
secret: Rún cliaint secret: Rún cliaint
title: 'Ainm feidhmchláir: %{name}' title: 'Ainm feidhmchláir: %{name}'
authorizations: authorizations:
buttons: buttons:
authorize: Ceadaigh authorize: Ceadaigh
deny: Diúltaigh deny: Diúltaigh
error:
title: Tharla earráid
new:
prompt_html: Ba mhaith le %{client_name} cead rochtain a fháil ar do chuntas. Is iarratas tríú páirtí é. <strong>Mura bhfuil muinín agat as, níor cheart duit é a údarú.</strong>
review_permissions: Ceadanna a athbhreithniú
title: Tá údarú ag teastáil
show:
title: Cóipeáil an cód údaraithe seo agus greamaigh don fheidhmchlár é.
authorized_applications: authorized_applications:
buttons:
revoke: Cúlghair
confirmations: confirmations:
revoke: An bhfuil tú cinnte? revoke: An bhfuil tú cinnte?
index: index:
authorized_at: Ceadaithe ar %{date}
description_html: Is feidhmchláir iad seo ar féidir rochtain a fháil ar do chuntas leis an API. Má tá feidhmchláir ann nach n-aithníonn tú anseo, nó má tá feidhmchlár mí-iompair, is féidir leat a rochtain a chúlghairm.
last_used_at: Úsáidte an uair dheireanach ar %{date}
never_used: Ná húsáideadh
scopes: Ceadanna scopes: Ceadanna
superapp: Inmheánach superapp: Inmheánach
title: Diarratais údaraithe
errors:
messages:
access_denied: Shéan úinéir na hacmhainne nó an freastalaí údaraithe an t-iarratas.
credential_flow_not_configured: Theip ar shreabhadh Dintiúir Pasfhocal Úinéir Acmhainne toisc go raibh Doorkeeper.configure.resource_owner_from_credentials díchumraithe.
invalid_client: Theip ar fhíordheimhniú cliant de bharr cliant anaithnid, níl fíordheimhniú cliant san áireamh, nó modh fíordheimhnithe nach dtacaítear leis.
invalid_grant: Tá an deonú údaraithe ar choinníoll neamhbhailí, imithe in éag, cúlghairthe, nach ionann é agus an URI atreoraithe a úsáideadh san iarratas ar údarú, nó gur eisíodh é chuig cliant eile.
invalid_redirect_uri: Níl an uri atreoraithe atá san áireamh bailí.
invalid_request:
missing_param: 'Paraiméadar riachtanach in easnamh: %{value}.'
request_not_authorized: Ní mór an t-iarratas a údarú. Tá an paraiméadar riachtanach chun iarratas a údarú in easnamh nó neamhbhailí.
unknown: Tá paraiméadar riachtanach in easnamh ar an iarratas, folaíonn sé luach paraiméadar nach dtacaítear leis, nó tá sé míchumtha ar shlí eile.
invalid_resource_owner: Níl na dintiúir úinéara acmhainne a soláthraíodh bailí, nó ní féidir úinéir na hacmhainne a aimsiú
invalid_scope: Tá an scóip iarrtha neamhbhailí, anaithnid nó míchumtha.
invalid_token:
expired: Chuaigh an comhartha rochtana in éag
revoked: Cúlghairmeadh an comhartha rochtana
unknown: Tá an comhartha rochtana neamhbhailí
resource_owner_authenticator_not_configured: Theip ar aimsiú Úinéir Acmhainne toisc go bhfuil Doorkeeper.configure.resource_owner_authenticator díchumraithe.
server_error: Tháinig an freastalaí údaraithe ar riocht gan choinne a chuir cosc air an t-iarratas a chomhlíonadh.
temporarily_unavailable: Ní féidir leis an bhfreastalaí údaraithe an t-iarratas a láimhseáil mar gheall ar ró-ualú sealadach nó cothabháil an fhreastalaí.
unauthorized_client: Níl an cliant údaraithe an t-iarratas seo a dhéanamh leis an modh seo.
unsupported_grant_type: Ní thacaíonn an freastalaí údaraithe leis an gcineál deontais údaraithe.
unsupported_response_type: Ní thacaíonn an freastalaí údaraithe leis an gcineál freagartha seo.
flash:
applications:
create:
notice: Cruthaíodh feidhmchlár.
destroy:
notice: Scriosadh an feidhmchlár.
update:
notice: Feidhmchlár nuashonraithe.
authorized_applications:
destroy:
notice: Cúlghairmeadh an t-iarratas.
grouped_scopes: grouped_scopes:
access:
read: Rochtain inléite-amháin
read/write: Léigh agus scríobh rochtain
write: Rochtain scríofa-amháin
title: title:
accounts: Cuntais accounts: Cuntais
admin/accounts: Cuntas a riar
admin/all: Feidhmeanna riaracháin go léir
admin/reports: Tuarascálacha a riar
all: Rochtain iomlán ar do chuntas Mastodon
blocks: Bloic
bookmarks: Leabharmharcanna bookmarks: Leabharmharcanna
conversations: Comhráite conversations: Comhráite
crypto: Criptiú ceann-go-deireadh
favourites: Ceanáin favourites: Ceanáin
filters: Scagairí filters: Scagairí
follow: Leanann, Múchann agus Blocálann
follows: Cuntais leanta follows: Cuntais leanta
lists: Liostaí lists: Liostaí
media: Ceangaltáin meán
mutes: Múchann
notifications: Fógraí notifications: Fógraí
profile: Do phróifíl Mastodon
push: Fógraí a bhrú
reports: Tuarascálacha
search: Cuardaigh search: Cuardaigh
statuses: Postálacha statuses: Postálacha
layouts:
admin:
nav:
applications: Feidhmchláir
oauth2_provider: Soláthraí OAuth2
application:
title: Tá údarú OAuth riachtanach
scopes: scopes:
admin:read: léigh na sonraí go léir ar an bhfreastalaí
admin:read:accounts: faisnéis íogair na gcuntas go léir a léamh
admin:read:canonical_email_blocks: léigh faisnéis íogair ar gach bloc ríomhphoist canónach
admin:read:domain_allows: léigh faisnéis íogair gach fearainn
admin:read:domain_blocks: léigh faisnéis íogair gach bloc fearainn
admin:read:email_domain_blocks: léigh faisnéis íogair gach bloc fearainn ríomhphoist
admin:read:ip_blocks: léigh faisnéis íogair gach bloic IP
admin:read:reports: faisnéis íogair na dtuarascálacha agus na gcuntas tuairiscithe go léir a léamh
admin:write: na sonraí go léir ar an bhfreastalaí a mhodhnú
admin:write:accounts: gníomhartha modhnóireachta a dhéanamh ar chuntais
admin:write:canonical_email_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna ríomhphoist chanónacha
admin:write:domain_allows: gníomhartha modhnóireachta a dhéanamh ar cheadaíonn fearainn
admin:write:domain_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna fearainn
admin:write:email_domain_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna fearainn ríomhphoist
admin:write:ip_blocks: gníomhartha modhnóireachta a dhéanamh ar bhlocanna IP
admin:write:reports: gníomhartha modhnóireachta a dhéanamh ar thuarascálacha
crypto: úsáid criptiú ceann-go-ceann
follow: caidrimh chuntais a mhodhnú
profile: léigh faisnéis phróifíle do chuntais amháin
push: faigh do bhrúfhógraí
read: léigh sonraí do chuntais go léir
read:accounts: féach eolas cuntais
read:blocks: féach ar do bloic
read:bookmarks: féach ar do leabharmharcanna
read:favourites: féach ar do cheanáin read:favourites: féach ar do cheanáin
read:filters: féach ar do chuid scagairí
read:follows: féach do chuid seo a leanas
read:lists: féach ar do liostaí
read:mutes: féach ar do bhalbh
read:notifications: féach ar do chuid fógraí
read:reports: féach ar do thuarascálacha
read:search: cuardach ar do shon
read:statuses: féach ar gach post
write: sonraí do chuntais go léir a mhodhnú
write:accounts: do phróifíl a mhodhnú
write:blocks: cuntais agus fearainn a bhlocáil
write:bookmarks: poist leabharmharcála
write:conversations: comhráite balbh agus scrios
write:favourites: poist is fearr leat
write:filters: cruthaigh scagairí write:filters: cruthaigh scagairí
write:follows: daoine a leanúint
write:lists: cruthaigh liostaí write:lists: cruthaigh liostaí
write:media: uaslódáil comhaid meáin
write:mutes: balbhaigh daoine agus comhráite
write:notifications: soiléir do chuid fógraí
write:reports: tuairisc a thabhairt do dhaoine eile
write:statuses: foilsigh poist

File diff suppressed because it is too large Load Diff

View File

@ -226,6 +226,7 @@ gl:
update_custom_emoji: Actualizar emoticona personalizada update_custom_emoji: Actualizar emoticona personalizada
update_domain_block: Actualizar bloqueo do dominio update_domain_block: Actualizar bloqueo do dominio
update_ip_block: Actualizar regra IP update_ip_block: Actualizar regra IP
update_report: Actualización da denuncia
update_status: Actualizar publicación update_status: Actualizar publicación
update_user_role: Actualizar Rol update_user_role: Actualizar Rol
actions: actions:
@ -638,6 +639,7 @@ gl:
report: 'Denuncia #%{id}' report: 'Denuncia #%{id}'
reported_account: Conta denunciada reported_account: Conta denunciada
reported_by: Denunciado por reported_by: Denunciado por
reported_with_application: Denunciado coa aplicación
resolved: Resolto resolved: Resolto
resolved_msg: Resolveuse con éxito a denuncia! resolved_msg: Resolveuse con éxito a denuncia!
skip_to_actions: Ir a accións skip_to_actions: Ir a accións

View File

@ -32,6 +32,8 @@ hi:
silence: सीमा silence: सीमा
silenced: सीमित silenced: सीमित
title: खाते title: खाते
reports:
reported_with_application: एप्लीकेशन से रिपोर्ट किया गया
system_checks: system_checks:
upload_check_privacy_error: upload_check_privacy_error:
message_html: "<strong> आपके वेब सर्वर का कन्फिगरेशन सही नहीं है। उपयोगकर्ताओं की निजता खतरे में है। </strong>" message_html: "<strong> आपके वेब सर्वर का कन्फिगरेशन सही नहीं है। उपयोगकर्ताओं की निजता खतरे में है। </strong>"

View File

@ -226,7 +226,7 @@ hu:
update_custom_emoji: Egyéni emodzsi frissítése update_custom_emoji: Egyéni emodzsi frissítése
update_domain_block: Domain tiltás frissítése update_domain_block: Domain tiltás frissítése
update_ip_block: IP-szabály frissítése update_ip_block: IP-szabály frissítése
update_report: Bejelentés Frissítése update_report: Bejelentés frissítése
update_status: Bejegyzés frissítése update_status: Bejegyzés frissítése
update_user_role: Szerepkör frissítése update_user_role: Szerepkör frissítése
actions: actions:

View File

@ -211,6 +211,7 @@ bg:
setting_default_privacy: Поверителност на публикуване setting_default_privacy: Поверителност на публикуване
setting_default_sensitive: Все да се бележи мултимедията като деликатна setting_default_sensitive: Все да се бележи мултимедията като деликатна
setting_delete_modal: Показване на прозорче за потвърждение преди изтриване на публикация setting_delete_modal: Показване на прозорче за потвърждение преди изтриване на публикация
setting_disable_hover_cards: Изключване на прегледа на профила, премествайки показалеца отгоре
setting_disable_swiping: Деактивиране на бързо плъзгащи движения setting_disable_swiping: Деактивиране на бързо плъзгащи движения
setting_display_media: Показване на мултимедия setting_display_media: Показване на мултимедия
setting_display_media_default: Стандартно setting_display_media_default: Стандартно
@ -242,11 +243,13 @@ bg:
warn: Скриване зад предупреждение warn: Скриване зад предупреждение
form_admin_settings: form_admin_settings:
activity_api_enabled: Публикуване на агрегатна статистика относно потребителската дейност в API activity_api_enabled: Публикуване на агрегатна статистика относно потребителската дейност в API
app_icon: Икона на приложение
backups_retention_period: Период за съхранение на потребителския архив backups_retention_period: Период за съхранение на потребителския архив
bootstrap_timeline_accounts: Винаги да се препоръчват следните акаунти на нови потребители bootstrap_timeline_accounts: Винаги да се препоръчват следните акаунти на нови потребители
closed_registrations_message: Съобщение при неналична регистрация closed_registrations_message: Съобщение при неналична регистрация
content_cache_retention_period: Период на запазване на отдалечено съдържание content_cache_retention_period: Период на запазване на отдалечено съдържание
custom_css: Персонализиран CSS custom_css: Персонализиран CSS
favicon: Сайтоикона
mascot: Плашило талисман по избор (остаряло) mascot: Плашило талисман по избор (остаряло)
media_cache_retention_period: Период на запазване на мултимедийния кеш media_cache_retention_period: Период на запазване на мултимедийния кеш
peers_api_enabled: Публикуване на списъка с открити сървъри в API peers_api_enabled: Публикуване на списъка с открити сървъри в API

View File

@ -2,47 +2,239 @@
ga: ga:
simple_form: simple_form:
hints: hints:
account:
discoverable: Seans go mbeidh do phostálacha poiblí agus do phróifíl le feiceáil nó molta i réimsí éagsúla de Mastodon agus is féidir do phróifíl a mholadh dúsáideoirí eile.
display_name: D'ainm iomlán nó d'ainm spraoi.
fields: Do leathanach baile, forainmneacha, aois, rud ar bith is mian leat.
indexable: Seans go mbeidh do phostálacha poiblí le feiceáil sna torthaí cuardaigh ar Mastodon. Seans go mbeidh daoine a didirghníomhaigh le do phostálacha in ann iad a chuardach beag beann ar.
note: 'Is féidir leat @trá a dhéanamh ar dhaoine eile nó #hashtags.'
show_collections: Beidh daoine in ann brabhsáil trí do seo a leanas agus do leanúna. Feicfidh na daoine a leanann tú go leanann tú iad beag beann ar.
unlocked: Beidh daoine in ann tú a leanúint gan cead a iarraidh. Díthiceáil an dteastaíonn uait athbhreithniú a dhéanamh ar iarratais leantacha agus roghnaigh cé acu an nglacfaidh nó an diúltóidh tú do leantóirí nua.
account_alias: account_alias:
acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú uaidh acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú uaidh
account_migration: account_migration:
acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú chuige acct: Sonraigh ainm@fearann don chuntas ar mhaith leat aistriú chuige
account_warning_preset:
text: Is féidir leat comhréir na bpost a úsáid, mar URLanna, hashtags agus lua
title: Roghnach. Níl sé le feiceáil ag an bhfaighteoir
admin_account_action: admin_account_action:
include_statuses: Feicfidh an t-úsáideoir cé na poist ba chúis leis an ngníomh modhnóireachta nó leis an rabhadh
send_email_notification: Gheobhaidh an t-úsáideoir míniú ar an méid a tharla lena chuntas
text_html: Roghnach. Is féidir leat comhréir phoist a úsáid. Is féidir leat <a href="%{path}">réamhshocruithe rabhaidh a chur leis</a> chun am a shábháil
type_html: Roghnaigh cad atá le déanamh le <strong>%{acct}</strong>
types: types:
disable: Cuir cosc ar an úsáideoir a chuntas a úsáid, ach ná scrios ná folaigh a bhfuil ann. disable: Cuir cosc ar an úsáideoir a chuntas a úsáid, ach ná scrios ná folaigh a bhfuil ann.
none: Bain úsáid as seo chun rabhadh a sheoladh chuig an úsáideoir, gan aon ghníomh eile a spreagadh.
sensitive: Iallach a chur ar cheangaltáin meán an úsáideora seo go léir a bheith íogair.
silence: Cosc a chur ar an úsáideoir ó bheith in ann postáil le hinfheictheacht phoiblí, a gcuid postálacha agus fógraí a cheilt ar dhaoine nach leanann iad. Dúnann sé gach tuairisc i gcoinne an chuntais seo.
suspend: Cosc ar aon idirghníomhaíocht ón gcuntas seo nó chuig an gcuntas seo agus scrios a bhfuil ann. Inchúlaithe laistigh de 30 lá. Dúnann sé gach tuairisc i gcoinne an chuntais seo.
warning_preset_id: Roghnach. Is féidir leat téacs saincheaptha a chur le deireadh an réamhshocraithe fós
announcement:
all_day: Nuair a dhéantar iad a sheiceáil, ní thaispeánfar ach dátaí an raon ama
ends_at: Roghnach. Beidh an fógra neamhfhoilsithe go huathoibríoch ag an am seo
scheduled_at: Fág bán chun an fógra a fhoilsiú láithreach
starts_at: Roghnach. I gcás go bhfuil d'fhógra ceangailte le raon ama ar leith
text: Is féidir leat comhréir phoist a úsáid. Tabhair aird ar an spás a ghlacfaidh an fógra ar scáileán an úsáideora
appeal:
text: Ní féidir leat achomharc a dhéanamh ach uair amháin ar stailc
defaults: defaults:
autofollow: Leanfaidh daoine a chláraíonn tríd an gcuireadh thú go huathoibríoch
avatar: WEBP, PNG, GIF nó JPG. %{size} ar a mhéad. Íoslaghdófar é go %{dimensions}px
bot: Cuir in iúl do dhaoine eile go ndéanann an cuntas gníomhartha uathoibrithe den chuid is mó agus go mbfhéidir nach ndéanfar monatóireacht air
context: Comhthéacs amháin nó comhthéacsanna iolracha inar cheart go mbeadh feidhm ag an scagaire
current_password: Chun críocha slándála cuir isteach pasfhocal an chuntais reatha
current_username: Le deimhniú, cuir isteach ainm úsáideora an chuntais reatha
digest: Seoltar é tar éis tréimhse fhada neamhghníomhaíochta amháin agus sa chás sin amháin go bhfuil aon teachtaireachtaí pearsanta faighte agat agus tú as láthair
email: Seolfar ríomhphost deimhnithe chugat
header: WEBP, PNG, GIF nó JPG. %{size} ar a mhéad. Íoslaghdófar é go %{dimensions}px
inbox_url: Cóipeáil an URL ó leathanach tosaigh an athsheachadáin is mian leat a úsáid
irreversible: Imeoidh postálacha scagtha go dochúlaithe, fiú má bhaintear an scagaire níos déanaí
locale: Teanga an chomhéadain úsáideora, r-phoist agus fógraí brú
password: Úsáid ar a laghad 8 gcarachtar
phrase: Déanfar é a mheaitseáil beag beann ar chásáil an téacs nó ar an ábhar atá ag tabhairt foláireamh do phostáil
scopes: Cé na APIanna a mbeidh cead ag an bhfeidhmchlár rochtain a fháil orthu. Má roghnaíonn tú raon feidhme barrleibhéil, ní gá duit cinn aonair a roghnú.
setting_aggregate_reblogs: Ná taispeáin treisithe nua do phoist a treisíodh le déanaí (ní dhéanann difear ach do threisithe nuafhaighte)
setting_always_send_emails: Go hiondúil ní sheolfar fógraí ríomhphoist agus tú ag úsáid Mastodon go gníomhach
setting_default_sensitive: Tá meáin íogair i bhfolach de réir réamhshocraithe agus is féidir iad a nochtadh le cliceáil
setting_display_media_default: Folaigh meáin atá marcáilte mar íogair
setting_display_media_hide_all: Folaigh meáin i gcónaí setting_display_media_hide_all: Folaigh meáin i gcónaí
setting_display_media_show_all: Taispeáin meáin i gcónaí setting_display_media_show_all: Taispeáin meáin i gcónaí
setting_use_blurhash: Tá grádáin bunaithe ar dhathanna na n-amharcanna ceilte ach cuireann siad salach ar aon mhionsonraí
setting_use_pending_items: Folaigh nuashonruithe amlíne taobh thiar de chlic seachas an fotha a scrollú go huathoibríoch
username: Is féidir leat litreacha, uimhreacha, agus béim a úsáid
whole_word: Nuair a bhíonn an eochairfhocal nó frása alfa-uimhriúil amháin, ní chuirfear i bhfeidhm é ach amháin má mheaitseálann sé an focal iomlán
domain_allow:
domain: Beidh an fearann seo in ann sonraí a fháil ón bhfreastalaí seo agus déanfar sonraí a thagann isteach uaidh a phróiseáil agus a stóráil
email_domain_block:
domain: Is féidir gurb é seo an t-ainm fearainn a thaispeánann sa seoladh ríomhphoist nó sa taifead MX a úsáideann sé. Déanfar iad a sheiceáil nuair a chláraítear iad.
with_dns_records: Déanfar iarracht taifid DNS an fhearainn tugtha a réiteach agus cuirfear bac ar na torthaí freisin
featured_tag:
name: 'Seo cuid de na hashtags a dúsáid tú le déanaí:'
filters:
action: Roghnaigh an gníomh ba cheart a dhéanamh nuair a mheaitseálann postáil an scagaire
actions:
hide: Cuir an t-ábhar scagtha i bhfolach go hiomlán, ag iompar amhail is nach raibh sé ann
warn: Folaigh an t-ábhar scagtha taobh thiar de rabhadh a luann teideal an scagaire
form_admin_settings:
activity_api_enabled: Áireamh na bpost a foilsíodh go háitiúil, úsáideoirí gníomhacha, agus clárúcháin nua i buicéid seachtainiúla
app_icon: WEBP, PNG, GIF nó JPG. Sáraíonn sé an deilbhín réamhshocraithe aipe ar ghléasanna soghluaiste le deilbhín saincheaptha.
backups_retention_period: Tá an cumas ag úsáideoirí cartlanna dá gcuid post a ghiniúint le híoslódáil níos déanaí. Nuair a bheidh luach dearfach socraithe, scriosfar na cartlanna seo go huathoibríoch ó do stór tar éis an líon sonraithe laethanta.
bootstrap_timeline_accounts: Cuirfear na cuntais seo ar bharr na moltaí a leanann úsáideoirí nua.
closed_registrations_message: Ar taispeáint nuair a dhúntar clárúcháin
content_cache_retention_period: Scriosfar gach postáil ó fhreastalaithe eile (lena n-áirítear treisithe agus freagraí) tar éis an líon sonraithe laethanta, gan aird ar aon idirghníomhaíocht úsáideora áitiúil leis na postálacha sin. Áirítear leis seo postálacha ina bhfuil úsáideoir áitiúil tar éis é a mharcáil mar leabharmharcanna nó mar cheanáin. Caillfear tagairtí príobháideacha idir úsáideoirí ó chásanna éagsúla freisin agus ní féidir iad a athchóiriú. Tá úsáid an tsocraithe seo beartaithe le haghaidh cásanna sainchuspóra agus sáraítear go leor ionchais úsáideoirí nuair a chuirtear i bhfeidhm é le haghaidh úsáid ghinearálta.
custom_css: Is féidir leat stíleanna saincheaptha a chur i bhfeidhm ar an leagan gréasáin de Mastodon.
favicon: WEBP, PNG, GIF nó JPG. Sáraíonn sé an favicon Mastodon réamhshocraithe le deilbhín saincheaptha.
mascot: Sáraíonn sé an léaráid san ardchomhéadan gréasáin.
media_cache_retention_period: Déantar comhaid meán ó phoist a dhéanann cianúsáideoirí a thaisceadh ar do fhreastalaí. Nuair a bheidh luach dearfach socraithe, scriosfar na meáin tar éis an líon sonraithe laethanta. Má iarrtar na sonraí meán tar éis é a scriosadh, déanfar é a ath-íoslódáil, má tá an t-ábhar foinse fós ar fáil. Mar gheall ar shrianta ar cé chomh minic is atá cártaí réamhamhairc ag vótaíocht do shuíomhanna tríú páirtí, moltar an luach seo a shocrú go 14 lá ar a laghad, nó ní dhéanfar cártaí réamhamhairc naisc a nuashonrú ar éileamh roimh an am sin.
peers_api_enabled: Liosta de na hainmneacha fearainn ar tháinig an freastalaí seo orthu sa choinbhleacht. Níl aon sonraí san áireamh anseo faoi cé acu an ndéanann tú cónascadh le freastalaí ar leith, díreach go bhfuil a fhios ag do fhreastalaí faoi. Úsáideann seirbhísí a bhailíonn staitisticí ar chónaidhm go ginearálta é seo.
profile_directory: Liostaíonn an t-eolaire próifíle na húsáideoirí go léir a roghnaigh isteach le bheith in-aimsithe.
require_invite_text: Nuair a bhíonn faomhadh láimhe ag teastáil le haghaidh clárúcháin, déan an "Cén fáth ar mhaith leat a bheith páirteach?" ionchur téacs éigeantach seachas roghnach
site_contact_email: Conas is féidir le daoine dul i dteagmháil leat le haghaidh fiosrúchán dlíthiúil nó tacaíochta.
site_contact_username: Conas is féidir le daoine dul i dteagmháil leat ar Mastodon.
site_extended_description: Aon fhaisnéis bhreise a dfhéadfadh a bheith úsáideach do chuairteoirí agus dúsáideoirí. Is féidir é a struchtúrú le comhréir Markdown.
site_short_description: Cur síos gairid chun cabhrú le do fhreastalaí a aithint go uathúil. Cé atá á rith, cé dó a bhfuil sé?
site_terms: Bain úsáid as do pholasaí príobháideachta féin nó fág bán é chun an réamhshocrú a úsáid. Is féidir é a struchtúrú le comhréir Markdown.
site_title: Conas is féidir le daoine tagairt a dhéanamh do do fhreastalaí seachas a ainm fearainn.
status_page_url: URL leathanach inar féidir le daoine stádas an fhreastalaí seo a fheiceáil le linn briseadh amach
theme: Téama a fheiceann cuairteoirí logáilte amach agus úsáideoirí nua.
thumbnail: Íomhá thart ar 2:1 ar taispeáint taobh le faisnéis do fhreastalaí.
timeline_preview: Beidh cuairteoirí logáilte amach in ann na postálacha poiblí is déanaí atá ar fáil ar an bhfreastalaí a bhrabhsáil.
trendable_by_default: Léim ar athbhreithniú láimhe ar ábhar treochta. Is féidir míreanna aonair a bhaint as treochtaí fós tar éis an fhíric.
trends: Léiríonn treochtaí cé na postálacha, hashtags agus scéalta nuachta atá ag tarraingt ar do fhreastalaí.
trends_as_landing_page: Taispeáin inneachar treochta d'úsáideoirí agus do chuairteoirí atá logáilte amach in ionad cur síos ar an bhfreastalaí seo. Éilíonn treochtaí a chumasú.
form_challenge:
current_password: Tá tú ag dul isteach i limistéar slán
imports:
data: Comhad CSV easpórtáilte ó fhreastalaí Mastodon eile
invite_request:
text: Cabhróidh sé seo linn diarratas a athbhreithniú
ip_block:
comment: Roghnach. Cuimhnigh cén fáth ar chuir tú an riail seo leis.
expires_in: Is acmhainn chríochta iad seoltaí IP, uaireanta roinntear iad agus is minic a athraíonn lámha. Ar an gcúis seo, ní mholtar bloic IP éiginnte.
ip: Cuir isteach seoladh IPv4 nó IPv6. Is féidir leat raonta iomlána a bhlocáil ag baint úsáide as an chomhréir CIDR. Bí cúramach gan tú féin a ghlasáil amach!
severities:
no_access: Cuir bac ar rochtain ar na hacmhainní go léir
sign_up_block: Ní bheidh clárú nua indéanta
sign_up_requires_approval: Beidh do cheadú ag teastáil le haghaidh clárúcháin nua
severity: Roghnaigh cad a tharlóidh le hiarratais ón IP seo
rule:
hint: Roghnach. Tabhair tuilleadh sonraí faoin riail
text: Déan cur síos ar riail nó riachtanas d'úsáideoirí ar an bhfreastalaí seo. Déan iarracht é a choinneáil gearr agus simplí
sessions:
otp: 'Cuir isteach an cód dhá fhachtóir ginte ag d''aip ghutháin nó úsáid ceann de do chóid athshlánaithe:'
webauthn: Más eochair USB atá ann déan cinnte é a chur isteach agus, más gá, tapáil í.
settings:
indexable: Seans go mbeidh do leathanach próifíle le feiceáil i dtorthaí cuardaigh ar Google, Bing agus eile.
show_application: Beidh tú in ann a fheiceáil i gcónaí cén aip a dfhoilsigh do phostáil beag beann ar.
tag:
name: Ní féidir leat ach cásáil na litreacha a athrú, mar shampla, chun é a dhéanamh níos inléite
user:
chosen_languages: Nuair a dhéantar iad a sheiceáil, ní thaispeánfar ach postálacha i dteangacha roghnaithe in amlínte poiblí
role: Rialaíonn an ról na ceadanna atá ag an úsáideoir
user_role:
color: Dath le húsáid don ról ar fud an Chomhéadain, mar RGB i bhformáid heicsidheachúlach
highlighted: Déanann sé seo an ról le feiceáil go poiblí
name: Ainm poiblí an róil, má tá an ról socraithe le taispeáint mar shuaitheantas
permissions_as_keys: Beidh rochtain ag úsáideoirí a bhfuil an ról seo acu ar...
position: Cinneann ról níos airde réiteach coinbhleachta i gcásanna áirithe. Ní féidir gníomhartha áirithe a dhéanamh ach amháin ar róil a bhfuil tosaíocht níos ísle acu
webhook:
events: Roghnaigh imeachtaí le seoladh
template: Cum do phálasta JSON féin ag baint úsáide as idirshuíomh athróg. Fág bán le haghaidh JSON réamhshocraithe.
url: An áit a seolfar imeachtaí chuig
labels: labels:
account: account:
discoverable: Próifíl gné agus postálacha in halgartaim fionnachtana
fields: fields:
name: Lipéad name: Lipéad
value: Ábhar
indexable: Cuir postálacha poiblí san áireamh sna torthaí cuardaigh
show_collections: Taispeáin seo a leanas agus leanúna ar phróifíl
unlocked: Glac le leantóirí nua go huathoibríoch
account_alias:
acct: Láimhseáil an seanchuntais
account_migration:
acct: Láimhseáil an chuntais nua
account_warning_preset: account_warning_preset:
text: Téacs réamhshocraithe
title: Teideal title: Teideal
admin_account_action: admin_account_action:
include_statuses: Cuir postálacha tuairiscithe san áireamh sa ríomhphost
send_email_notification: Cuir an t-úsáideoir ar an eolas trí ríomhphost
text: Rabhadh saincheaptha text: Rabhadh saincheaptha
type: Gníomh
types: types:
disable: Reoigh disable: Reoigh
none: Seol rabhadh none: Seol rabhadh
sensitive: Íogair
silence: Teorannaigh silence: Teorannaigh
suspend: Cuir ar fionraí suspend: Cuir ar fionraí
warning_preset_id: Bain úsáid as réamhshocrú rabhaidh
announcement: announcement:
all_day: Imeacht uile-lae
ends_at: Deireadh an imeachta
scheduled_at: Foilsiú sceideal
starts_at: Tús na hócáide
text: Fógra text: Fógra
appeal:
text: Mínigh cén fáth ar cheart an cinneadh seo a fhreaschur
defaults: defaults:
autofollow: Tabhair cuireadh do chuntas a leanúint
avatar: Abhatár avatar: Abhatár
bot: Is cuntas uathoibrithe é seo
chosen_languages: Scag teangacha
confirm_new_password: Deimhnigh pasfhocal nua
confirm_password: Deimhnigh Pasfhocal
context: Comhthéacsanna a scagadh
current_password: Pasfhocal reatha
data: Sonraí data: Sonraí
display_name: Ainm taispeána display_name: Ainm taispeána
email: Seoladh ríomhphoist email: Seoladh ríomhphoist
expires_in: In éag tar éis
fields: Réimsí breise
header: Ceanntásc header: Ceanntásc
honeypot: "%{label} (ná líon isteach)"
inbox_url: URL an bhosca isteach sealaíochta
irreversible: Droim ar aghaidh in ionad bheith ag folaigh
locale: Teanga comhéadan
max_uses: Uaslíon úsáidí
new_password: Pasfhocal nua new_password: Pasfhocal nua
note: Beathaisnéis note: Beathaisnéis
otp_attempt: Cód dhá-fhachtóir
password: Pasfhocal password: Pasfhocal
phrase: Eochairfhocal nó frása
setting_advanced_layout: Cumasaigh ardchomhéadan gréasáin
setting_aggregate_reblogs: Treisithe grúpa i línte ama
setting_always_send_emails: Seol fógraí ríomhphoist i gcónaí
setting_auto_play_gif: Gifs beoite go huathoibríoch a imirt
setting_boost_modal: Taispeáin dialóg deimhnithe roimh threisiú
setting_default_language: Teanga postála
setting_default_privacy: Postáil príobháideachta
setting_default_sensitive: Marcáil na meáin mar íogair i gcónaí
setting_delete_modal: Taispeáin dialóg deimhnithe sula scriostar postáil
setting_disable_hover_cards: Díchumasaigh réamhamharc próifíle ar ainlíon
setting_disable_swiping: Díchumasaigh gluaiseachtaí swiping
setting_display_media: Taispeáint meáin
setting_display_media_default: Réamhshocrú setting_display_media_default: Réamhshocrú
setting_display_media_hide_all: Cuir uile i bhfolach setting_display_media_hide_all: Cuir uile i bhfolach
setting_display_media_show_all: Taispeáin uile setting_display_media_show_all: Taispeáin uile
setting_expand_spoilers: Méadaigh postálacha atá marcáilte le rabhaidh inneachair i gcónaí
setting_hide_network: Folaigh do ghraf sóisialta
setting_reduce_motion: Laghdú ar an tairiscint i beochan
setting_system_font_ui: Úsáid cló réamhshocraithe an chórais
setting_theme: Téama suímh setting_theme: Téama suímh
setting_trends: Taispeáin treochtaí an lae inniu setting_trends: Taispeáin treochtaí an lae inniu
setting_unfollow_modal: Taispeáin dialóg deimhnithe sula ndíleanfaidh tú duine éigin
setting_use_blurhash: Taispeáin grádáin ildaite do mheáin fholaithe
setting_use_pending_items: Modh mall
severity: Déine
sign_in_token_attempt: Cód slándála
title: Teideal title: Teideal
type: Cineál iompórtála
username: Ainm úsáideora username: Ainm úsáideora
username_or_email: Ainm Úsáideora nó Ríomhphost
whole_word: Focal ar fad
email_domain_block:
with_dns_records: Cuir taifid MX agus IPanna an fhearainn san áireamh
featured_tag: featured_tag:
name: Haischlib name: Haischlib
filters: filters:
@ -50,27 +242,100 @@ ga:
hide: Cuir i bhfolach go hiomlán hide: Cuir i bhfolach go hiomlán
warn: Cuir i bhfolach le rabhadh warn: Cuir i bhfolach le rabhadh
form_admin_settings: form_admin_settings:
activity_api_enabled: Foilsigh staitisticí comhiomlána faoi ghníomhaíocht úsáideoirí san API
app_icon: Deilbhín aip
backups_retention_period: Tréimhse choinneála cartlainne úsáideora
bootstrap_timeline_accounts: Mol na cuntais seo d'úsáideoirí nua i gcónaí
closed_registrations_message: Teachtaireacht saincheaptha nuair nach bhfuil sínithe suas ar fáil
content_cache_retention_period: Tréimhse choinneála inneachair cianda
custom_css: CSS saincheaptha
favicon: Favicon
mascot: Mascóg saincheaptha (oidhreacht)
media_cache_retention_period: Tréimhse choinneála taisce meán
peers_api_enabled: Foilsigh liosta de na freastalaithe aimsithe san API
profile_directory: Cumasaigh eolaire próifíle
registrations_mode: Cé atá in ann clárú
require_invite_text: A cheangal ar chúis a bheith páirteach
show_domain_blocks: Taispeáin bloic fearainn
show_domain_blocks_rationale: Taispeáin cén fáth ar cuireadh bac ar fhearann
site_contact_email: R-phost teagmhála
site_contact_username: Ainm úsáideora teagmhála
site_extended_description: Cur síos fada site_extended_description: Cur síos fada
site_short_description: Cur síos freastalaí site_short_description: Cur síos freastalaí
site_terms: Polasaí príobháideachais site_terms: Polasaí príobháideachais
site_title: Ainm freastalaí site_title: Ainm freastalaí
status_page_url: URL an leathanaigh stádais
theme: Téama réamhshocraithe
thumbnail: Mionsamhail freastalaí
timeline_preview: Ceadaigh rochtain neamhdheimhnithe ar amlínte poiblí
trendable_by_default: Ceadaigh treochtaí gan athbhreithniú roimh ré
trends: Cumasaigh treochtaí
trends_as_landing_page: Úsáid treochtaí mar an leathanach tuirlingthe
interactions:
must_be_follower: Cuir bac ar fhógraí ó dhaoine nach leantóirí iad
must_be_following: Cuir bac ar fhógraí ó dhaoine nach leanann tú
must_be_following_dm: Cuir bac ar theachtaireachtaí díreacha ó dhaoine nach leanann tú
invite: invite:
comment: Ráiteas comment: Ráiteas
invite_request:
text: Cén fáth ar mhaith leat a bheith páirteach?
ip_block: ip_block:
comment: Ráiteas comment: Ráiteas
ip: IP ip: IP
severities:
no_access: Rochtain a bhlocáil
sign_up_block: Cuir bac ar chlárúcháin
sign_up_requires_approval: Teorainn le clárú
severity: Riail severity: Riail
notification_emails: notification_emails:
appeal: Déanann duine éigin achomharc i gcoinne chinneadh modhnóra
digest: Seol r-phoist achoimre
favourite: Is fearr le duine éigin do phostáil
follow: Lean duine éigin tú follow: Lean duine éigin tú
follow_request: D'iarr duine éigin tú a leanúint
mention: Luaigh duine éigin tú
pending_account: Ní mór athbhreithniú a dhéanamh ar chuntas nua
reblog: Mhol duine éigin do phostáil reblog: Mhol duine éigin do phostáil
report: Tá tuairisc nua curtha isteach
software_updates:
all: Fógra a thabhairt ar gach nuashonrú
critical: Fógra a thabhairt ar nuashonruithe ríthábhachtacha amháin
label: Tá leagan nua Mastodon ar fáil
none: Ná cuir nuashonruithe ar an eolas choíche (ní mholtar é)
patch: Fógra ar nuashonruithe bugfix
trending_tag: Teastaíonn athbhreithniú ar threocht nua
rule: rule:
hint: Eolas breise
text: Riail text: Riail
settings:
indexable: Cuir leathanach próifíle san innill chuardaigh
show_application: Taispeáin cén aip ónar sheol tú postáil
tag: tag:
listable: Lig don hashchlib seo a bheith le feiceáil i gcuardach agus i moltaí
name: Haischlib name: Haischlib
trendable: Lig don haischlib seo a bheith le feiceáil faoi threochtaí
usable: Lig do phostálacha an hashchlib seo a úsáid
user: user:
role: Ról role: Ról
time_zone: Crios ama
user_role: user_role:
color: Dath suaitheantas
highlighted: Taispeáin ról mar shuaitheantas ar phróifílí úsáideora
name: Ainm name: Ainm
permissions_as_keys: Ceadanna
position: Tosaíocht
webhook:
events: Imeachtaí cumasaithe
template: Teimpléad pá-ualach
url: URL críochphointe
'no': Níl
not_recommended: Ní mholtar
overridden: Sáraithe
recommended: Molta recommended: Molta
required: required:
mark: "*" mark: "*"
text: ag teastáil
title:
sessions:
webauthn: Úsáid ceann de d'eochracha slándála chun síniú isteach
'yes':

View File

@ -663,6 +663,7 @@ sl:
report: 'Prijavi #%{id}' report: 'Prijavi #%{id}'
reported_account: Prijavljeni račun reported_account: Prijavljeni račun
reported_by: Prijavil/a reported_by: Prijavil/a
reported_with_application: Prijavljeno s programom
resolved: Razrešeni resolved: Razrešeni
resolved_msg: Prijava je uspešno razrešena! resolved_msg: Prijava je uspešno razrešena!
skip_to_actions: Preskoči na dejanja skip_to_actions: Preskoči na dejanja

View File

@ -639,6 +639,7 @@ tr:
report: 'Şikayet #%{id}' report: 'Şikayet #%{id}'
reported_account: Şikayet edilen hesap reported_account: Şikayet edilen hesap
reported_by: Şikayet eden reported_by: Şikayet eden
reported_with_application: Uygulamayla bildirildi
resolved: Giderildi resolved: Giderildi
resolved_msg: Şikayet başarıyla çözümlendi! resolved_msg: Şikayet başarıyla çözümlendi!
skip_to_actions: İşlemlere atla skip_to_actions: İşlemlere atla

View File

@ -30,6 +30,7 @@ Rails.application.routes.draw do
/lists/(*any) /lists/(*any)
/links/(*any) /links/(*any)
/notifications/(*any) /notifications/(*any)
/notifications_v2/(*any)
/favourites /favourites
/bookmarks /bookmarks
/pinned /pinned

View File

@ -126,6 +126,7 @@
"tesseract.js": "^2.1.5", "tesseract.js": "^2.1.5",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"twitter-text": "3.1.0", "twitter-text": "3.1.0",
"use-debounce": "^10.0.0",
"webpack": "^4.47.0", "webpack": "^4.47.0",
"webpack-assets-manifest": "^4.0.6", "webpack-assets-manifest": "^4.0.6",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",

View File

@ -64,7 +64,7 @@ describe Admin::ReportsController do
describe 'POST #reopen' do describe 'POST #reopen' do
it 'reopens the report' do it 'reopens the report' do
report = Fabricate(:report) report = Fabricate(:report, action_taken_at: 3.days.ago)
put :reopen, params: { id: report } put :reopen, params: { id: report }
expect(response).to redirect_to(admin_report_path(report)) expect(response).to redirect_to(admin_report_path(report))
@ -89,7 +89,7 @@ describe Admin::ReportsController do
describe 'POST #unassign' do describe 'POST #unassign' do
it 'reopens the report' do it 'reopens the report' do
report = Fabricate(:report) report = Fabricate(:report, assigned_account_id: Account.last.id)
put :unassign, params: { id: report } put :unassign, params: { id: report }
expect(response).to redirect_to(admin_report_path(report)) expect(response).to redirect_to(admin_report_path(report))

View File

@ -90,7 +90,7 @@ RSpec.describe MediaAttachment, :attachment_processing do
media.destroy media.destroy
end end
it 'saves media attachment with correct file metadata' do it 'saves media attachment with correct file and size metadata' do
expect(media) expect(media)
.to be_persisted .to be_persisted
.and be_processing_complete .and be_processing_complete
@ -103,14 +103,12 @@ RSpec.describe MediaAttachment, :attachment_processing do
# Rack::Mime (used by PublicFileServerMiddleware) recognizes file extension # Rack::Mime (used by PublicFileServerMiddleware) recognizes file extension
expect(Rack::Mime.mime_type(extension, nil)).to eq content_type expect(Rack::Mime.mime_type(extension, nil)).to eq content_type
end
it 'saves media attachment with correct size metadata' do # Strip original file name
# strips original file name
expect(media.file_file_name) expect(media.file_file_name)
.to_not start_with '600x400' .to_not start_with '600x400'
# sets meta for original and thumbnail # Set meta for original and thumbnail
expect(media.file.meta.deep_symbolize_keys) expect(media.file.meta.deep_symbolize_keys)
.to include( .to include(
original: include( original: include(
@ -174,10 +172,18 @@ RSpec.describe MediaAttachment, :attachment_processing do
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('avatar.gif')) } let(:media) { Fabricate(:media_attachment, file: attachment_fixture('avatar.gif')) }
it 'sets correct file metadata' do it 'sets correct file metadata' do
expect(media.type).to eq 'gifv' expect(media)
expect(media.file_content_type).to eq 'video/mp4' .to have_attributes(
expect(media.file.meta['original']['width']).to eq 128 type: eq('gifv'),
expect(media.file.meta['original']['height']).to eq 128 file_content_type: eq('video/mp4')
)
expect(media_metadata)
.to include(
original: include(
width: eq(128),
height: eq(128)
)
)
end end
end end
@ -192,11 +198,19 @@ RSpec.describe MediaAttachment, :attachment_processing do
let(:media) { Fabricate(:media_attachment, file: attachment_fixture(fixture[:filename])) } let(:media) { Fabricate(:media_attachment, file: attachment_fixture(fixture[:filename])) }
it 'sets correct file metadata' do it 'sets correct file metadata' do
expect(media.type).to eq 'image' expect(media)
expect(media.file_content_type).to eq 'image/gif' .to have_attributes(
expect(media.file.meta['original']['width']).to eq fixture[:width] type: eq('image'),
expect(media.file.meta['original']['height']).to eq fixture[:height] file_content_type: eq('image/gif')
expect(media.file.meta['original']['aspect']).to eq fixture[:aspect] )
expect(media_metadata)
.to include(
original: include(
width: eq(fixture[:width]),
height: eq(fixture[:height]),
aspect: eq(fixture[:aspect])
)
)
end end
end end
end end
@ -204,39 +218,42 @@ RSpec.describe MediaAttachment, :attachment_processing do
describe 'ogg with cover art' do describe 'ogg with cover art' do
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) } let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) }
let(:expected_media_duration) { 0.235102 }
# The libvips and ImageMagick implementations produce different results
let(:expected_background_color) { Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4' }
it 'sets correct file metadata' do it 'sets correct file metadata' do
expect(media.type).to eq 'audio' expect(media)
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102) .to have_attributes(
expect(media.thumbnail.present?).to be true type: eq('audio'),
thumbnail: be_present,
file_file_name: not_eq('boop.ogg')
)
expect(media.file.meta['colors']['background']).to eq(expected_background_color) expect(media_metadata)
expect(media.file_file_name).to_not eq 'boop.ogg' .to include(
end original: include(duration: be_within(0.05).of(expected_media_duration)),
colors: include(background: eq(expected_background_color))
def expected_background_color )
# The libvips and ImageMagick implementations produce different results
Rails.configuration.x.use_vips ? '#268cd9' : '#3088d4'
end end
end end
describe 'mp3 with large cover art' do describe 'mp3 with large cover art' do
let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.mp3')) } let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.mp3')) }
let(:expected_media_duration) { 0.235102 }
it 'detects it as an audio file' do it 'detects file type and sets correct metadata' do
expect(media.type).to eq 'audio' expect(media)
end .to have_attributes(
type: eq('audio'),
it 'sets meta for the duration' do thumbnail: be_present,
expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102) file_file_name: not_eq('boop.mp3')
end )
expect(media_metadata)
it 'extracts thumbnail' do .to include(
expect(media.thumbnail.present?).to be true original: include(duration: be_within(0.05).of(expected_media_duration))
end )
it 'gives the file a random name' do
expect(media.file_file_name).to_not eq 'boop.mp3'
end end
end end
@ -274,4 +291,10 @@ RSpec.describe MediaAttachment, :attachment_processing do
expect(media.valid?).to be true expect(media.valid?).to be true
end end
end end
private
def media_metadata
media.file.meta.deep_symbolize_keys
end
end end

View File

@ -161,6 +161,7 @@ RSpec::Sidekiq.configure do |config|
end end
RSpec::Matchers.define_negated_matcher :not_change, :change RSpec::Matchers.define_negated_matcher :not_change, :change
RSpec::Matchers.define_negated_matcher :not_eq, :eq
RSpec::Matchers.define_negated_matcher :not_include, :include RSpec::Matchers.define_negated_matcher :not_include, :include
def request_fixture(name) def request_fixture(name)

View File

@ -40,14 +40,13 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end end
describe '#call' do describe '#call' do
it 'updates text' do it 'updates text and content warning' do
subject.call(status, json, json) subject.call(status, json, json)
expect(status.reload.text).to eq 'Hello universe' expect(status.reload)
end .to have_attributes(
text: eq('Hello universe'),
it 'updates content warning' do spoiler_text: eq('Show more')
subject.call(status, json, json) )
expect(status.reload.spoiler_text).to eq 'Show more'
end end
context 'when the changes are only in sanitized-out HTML' do context 'when the changes are only in sanitized-out HTML' do
@ -67,12 +66,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits and does not mark status edited' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status).to_not be_edited
it 'does not mark status as edited' do
expect(status.edited?).to be false
end end
end end
@ -90,15 +86,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits, mark status edited, or update text' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status.reload).to_not be_edited
it 'does not mark status as edited' do
expect(status.reload.edited?).to be false
end
it 'does not update the text' do
expect(status.reload.text).to eq 'Hello world' expect(status.reload.text).to eq 'Hello world'
end end
end end
@ -137,19 +127,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits, mark status edited, update text but does update tallies' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status.reload).to_not be_edited
it 'does not mark status as edited' do
expect(status.reload.edited?).to be false
end
it 'does not update the text' do
expect(status.reload.text).to eq 'Hello world' expect(status.reload.text).to eq 'Hello world'
end
it 'updates tallies' do
expect(status.poll.reload.cached_tallies).to eq [4, 3] expect(status.poll.reload.cached_tallies).to eq [4, 3]
end end
end end
@ -189,19 +170,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits, mark status edited, update text, or update tallies' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status.reload).to_not be_edited
it 'does not mark status as edited' do
expect(status.reload.edited?).to be false
end
it 'does not update the text' do
expect(status.reload.text).to eq 'Hello world' expect(status.reload.text).to eq 'Hello world'
end
it 'does not update tallies' do
expect(status.poll.reload.cached_tallies).to eq [0, 0] expect(status.poll.reload.cached_tallies).to eq [0, 0]
end end
end end
@ -213,13 +185,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
status.snapshot!(rate_limit: false) status.snapshot!(rate_limit: false)
end end
it 'does not create any edits' do it 'does not create any edits or update relevant attributes' do
expect { subject.call(status, json, json) }.to_not(change { status.reload.edits.pluck(&:id) })
end
it 'does not update the text, spoiler_text or edited_at' do
expect { subject.call(status, json, json) } expect { subject.call(status, json, json) }
.to_not(change { status.reload.attributes.slice('text', 'spoiler_text', 'edited_at').values }) .to not_change { status.reload.edits.pluck(&:id) }
.and(not_change { status.reload.attributes.slice('text', 'spoiler_text', 'edited_at').values })
end end
end end
@ -237,12 +206,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits or mark status edited' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status).to_not be_edited
it 'does not mark status as edited' do
expect(status.edited?).to be false
end end
end end
@ -261,12 +227,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'does not create any edits' do it 'does not create any edits or mark status edited' do
expect(status.reload.edits).to be_empty expect(status.reload.edits).to be_empty
end expect(status).to_not be_edited
it 'does not mark status as edited' do
expect(status.edited?).to be false
end end
end end
@ -412,11 +375,8 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'removes poll' do it 'removes poll and records media change in edit' do
expect(status.reload.poll).to be_nil expect(status.reload.poll).to be_nil
end
it 'records media change in edit' do
expect(status.edits.reload.last.poll_options).to be_nil expect(status.edits.reload.last.poll_options).to be_nil
end end
end end
@ -442,26 +402,21 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject.call(status, json, json) subject.call(status, json, json)
end end
it 'creates a poll' do it 'creates a poll and records media change in edit' do
poll = status.reload.poll poll = status.reload.poll
expect(poll).to_not be_nil expect(poll).to_not be_nil
expect(poll.options).to eq %w(Foo Bar Baz) expect(poll.options).to eq %w(Foo Bar Baz)
end
it 'records media change in edit' do
expect(status.edits.reload.last.poll_options).to eq %w(Foo Bar Baz) expect(status.edits.reload.last.poll_options).to eq %w(Foo Bar Baz)
end end
end end
it 'creates edit history' do it 'creates edit history and sets edit timestamp' do
subject.call(status, json, json) subject.call(status, json, json)
expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe'] expect(status.edits.reload.map(&:text))
end .to eq ['Hello world', 'Hello universe']
expect(status.reload.edited_at.to_s)
it 'sets edited timestamp' do .to eq '2021-09-08 22:39:25 UTC'
subject.call(status, json, json)
expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
end end
end end
end end

View File

@ -34,21 +34,14 @@ RSpec.describe FanOutOnWriteService do
context 'when status is public' do context 'when status is public' do
let(:visibility) { 'public' } let(:visibility) { 'public' }
it 'is added to the home feed of its author' do it 'adds status to home feed of author and followers and broadcasts', :inline_jobs do
expect(home_feed_of(alice)).to include status.id expect(status.id)
end .to be_in(home_feed_of(alice))
.and be_in(home_feed_of(bob))
.and be_in(home_feed_of(tom))
it 'is added to the home feed of a follower', :inline_jobs do
expect(home_feed_of(bob)).to include status.id
expect(home_feed_of(tom)).to include status.id
end
it 'is broadcast to the hashtag stream' do
expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to have_received(:publish).with('timeline:hashtag:hoge', anything)
expect(redis).to have_received(:publish).with('timeline:hashtag:hoge:local', anything) expect(redis).to have_received(:publish).with('timeline:hashtag:hoge:local', anything)
end
it 'is broadcast to the public stream' do
expect(redis).to have_received(:publish).with('timeline:public', anything) expect(redis).to have_received(:publish).with('timeline:public', anything)
expect(redis).to have_received(:publish).with('timeline:public:local', anything) expect(redis).to have_received(:publish).with('timeline:public:local', anything)
expect(redis).to have_received(:publish).with('timeline:public:media', anything) expect(redis).to have_received(:publish).with('timeline:public:media', anything)
@ -58,60 +51,41 @@ RSpec.describe FanOutOnWriteService do
context 'when status is limited' do context 'when status is limited' do
let(:visibility) { 'limited' } let(:visibility) { 'limited' }
it 'is added to the home feed of its author' do it 'adds status to home feed of author and mentioned followers and does not broadcast', :inline_jobs do
expect(home_feed_of(alice)).to include status.id expect(status.id)
end .to be_in(home_feed_of(alice))
.and be_in(home_feed_of(bob))
expect(status.id)
.to_not be_in(home_feed_of(tom))
it 'is added to the home feed of the mentioned follower', :inline_jobs do expect_no_broadcasting
expect(home_feed_of(bob)).to include status.id
end
it 'is not added to the home feed of the other follower' do
expect(home_feed_of(tom)).to_not include status.id
end
it 'is not broadcast publicly' do
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
end end
end end
context 'when status is private' do context 'when status is private' do
let(:visibility) { 'private' } let(:visibility) { 'private' }
it 'is added to the home feed of its author' do it 'adds status to home feed of author and followers and does not broadcast', :inline_jobs do
expect(home_feed_of(alice)).to include status.id expect(status.id)
end .to be_in(home_feed_of(alice))
.and be_in(home_feed_of(bob))
.and be_in(home_feed_of(tom))
it 'is added to the home feed of a follower', :inline_jobs do expect_no_broadcasting
expect(home_feed_of(bob)).to include status.id
expect(home_feed_of(tom)).to include status.id
end
it 'is not broadcast publicly' do
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
end end
end end
context 'when status is direct' do context 'when status is direct' do
let(:visibility) { 'direct' } let(:visibility) { 'direct' }
it 'is added to the home feed of its author' do it 'is added to the home feed of its author and mentioned followers and does not broadcast', :inline_jobs do
expect(home_feed_of(alice)).to include status.id expect(status.id)
end .to be_in(home_feed_of(alice))
.and be_in(home_feed_of(bob))
expect(status.id)
.to_not be_in(home_feed_of(tom))
it 'is added to the home feed of the mentioned follower', :inline_jobs do expect_no_broadcasting
expect(home_feed_of(bob)).to include status.id
end
it 'is not added to the home feed of the other follower' do
expect(home_feed_of(tom)).to_not include status.id
end
it 'is not broadcast publicly' do
expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
expect(redis).to_not have_received(:publish).with('timeline:public', anything)
end end
context 'when handling status updates' do context 'when handling status updates' do
@ -131,4 +105,13 @@ RSpec.describe FanOutOnWriteService do
end end
end end
end end
def expect_no_broadcasting
expect(redis)
.to_not have_received(:publish)
.with('timeline:hashtag:hoge', anything)
expect(redis)
.to_not have_received(:publish)
.with('timeline:public', anything)
end
end end

View File

@ -2913,6 +2913,7 @@ __metadata:
tiny-queue: "npm:^0.2.1" tiny-queue: "npm:^0.2.1"
twitter-text: "npm:3.1.0" twitter-text: "npm:3.1.0"
typescript: "npm:^5.0.4" typescript: "npm:^5.0.4"
use-debounce: "npm:^10.0.0"
webpack: "npm:^4.47.0" webpack: "npm:^4.47.0"
webpack-assets-manifest: "npm:^4.0.6" webpack-assets-manifest: "npm:^4.0.6"
webpack-bundle-analyzer: "npm:^4.8.0" webpack-bundle-analyzer: "npm:^4.8.0"
@ -17567,6 +17568,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"use-debounce@npm:^10.0.0":
version: 10.0.1
resolution: "use-debounce@npm:10.0.1"
peerDependencies:
react: ">=16.8.0"
checksum: 10c0/377a11814a708f5c392f465cbbe2d119a8a2635c8226cc5e30eba397c4436f8e8234385d069467b369d105ed0d3be733c6a08d8ae1004017c6d6f58f4d4c24d8
languageName: node
linkType: hard
"use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2": "use-isomorphic-layout-effect@npm:^1.1.1, use-isomorphic-layout-effect@npm:^1.1.2":
version: 1.1.2 version: 1.1.2
resolution: "use-isomorphic-layout-effect@npm:1.1.2" resolution: "use-isomorphic-layout-effect@npm:1.1.2"